[
https://issues.apache.org/jira/browse/FLINK-41368?page=com.atlassian.jira.plugin.system.issuetabpanels:all-tabpanel
]
Fabian Hueske updated FLINK-41368:
----------------------------------
Description:
[FLIP-579|https://cwiki.apache.org/confluence/display/FLINK/FLIP-579%3A+LATERAL+SNAPSHOT+Join]
adds {{LATERAL SNAPSHOT}} join, a stream enrichment join. It joins an
append-only probe table with the *current* state of an updating build table. A
load phase first fills the build-side state and buffers probe rows. Afterwards,
every probe row is joined immediately, without waiting for build-side
watermarks. Neither side needs a primary key. The result is non-deterministic
in streaming mode and deterministic in batch mode.
Docs: [LATERAL SNAPSHOT
Join|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/sql/reference/queries/joins/#lateral-snapshot-join]
and [Lateral Snapshot Join (Table
API)|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/dev/table/tableapi/#lateral-snapshot-join].
Planner, operator and restore tests are covered by unit and IT tests. This
ticket is about the end-to-end path on a real cluster: phases, metrics,
savepoints, batch mode and the Table API.
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/flip579/build /tmp/flip579/savepoints /tmp/flip579/checkpoints
{code}
In {{conf/config.yaml}}, change {{numberOfTaskSlots: 1}} to {{4}}, and append:
{code:yaml}
execution.checkpointing.dir: file:///tmp/flip579/checkpoints
execution.checkpointing.savepoint-dir: file:///tmp/flip579/savepoints
execution.checkpointing.interval: 10s
{code}
Start the cluster with {{./bin/start-cluster.sh}}. Save the following as
{{/tmp/flip579/ddl.sql}} and start the SQL Client with {{./bin/sql-client.sh
\-i /tmp/flip579/ddl.sql}}. Run every query in the sections below in that
session. Queries that start with {{INSERT INTO}} run detached on the cluster,
and you can see their result in
{{log/flink-<user>-taskexecutor-<n>-<host>.out}} (the {{print}} sink). Use
{{tail \-f}} on it, and do not delete or truncate the file while the
TaskManager is running (output is lost until the cluster restarts). Note the
line count instead.
The SQL Client only loads {{ddl.sql}} via {{\-i}} on start, and the tables and
views created in section 4 live only in the session. If you restart the client,
pass {{\-i}} again and recreate them.
The build side is a directory of {{debezium-json}} files that the filesystem
source monitors. A file you drop into the directory is read as build-side
changes, including updates and deletes. The probe side is a datagen source with
one order per second and the currency cycling A, B, C.
{code:sql}
SET 'table.local-time-zone' = 'UTC';
SET 'sql-client.execution.result-mode' = 'tableau';
CREATE TABLE rates (
currency STRING, rate DOUBLE, update_time TIMESTAMP(3),
WATERMARK FOR update_time AS update_time
) WITH (
'connector' = 'filesystem', 'path' = '/tmp/flip579/build',
'format' = 'debezium-json', 'source.monitor-interval' = '2s');
CREATE TABLE orders (
order_id BIGINT,
currency AS CASE order_id % 3 WHEN 0 THEN 'A' WHEN 1 THEN 'B' ELSE 'C' END,
ts AS LOCALTIMESTAMP,
WATERMARK FOR ts AS ts - 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 out_print (order_id BIGINT, currency STRING, rate DOUBLE) WITH
('connector' = 'print');
{code}
{{SET}} statements stay active for the rest of the SQL Client session. The
sections below tell you when to {{RESET}} a setting. Alternatively, restart the
SQL Client between sections.
The operator metrics are visible in the Web UI ({{http://localhost:8081}}):
open the job, click the {{LateralSnapshotJoin}} vertex and go to the Metrics
tab (the metric names are prefixed with {{LateralSnapshotJoin[n].}}). The same
values are available via REST, e.g. {{GET
/jobs/<job-id>/vertices/<vertex-id>/subtasks/metrics?get=LateralSnapshotJoin[9].currentPhase}}.
The operator number may differ. Metric values refresh only about every 10
seconds, so a check such as "immediately" or "after a few seconds" can look
stale for that long.
*Build-side changes are applied when the build-side watermark passes their row
time* (they are buffered until then, see the FLIP). A build row is therefore
invisible to the join until a later build-side watermark arrives. All build
files below end with a row for a dummy key {{Z}} with a later {{update_time}}
for this reason. It pushes the watermark, and no probe row matches it. Without
it, the last rows of a file stay invisible. In real pipelines the next
build-side record does this.
h3. 1. Load phase and flip via idle timeout
Start with an empty {{/tmp/flip579/build}} and submit:
{code:sql}
INSERT INTO out_print
SELECT o.order_id, o.currency, r.rate
FROM orders AS o
JOIN LATERAL SNAPSHOT(
input => TABLE rates,
on_time => DESCRIPTOR(update_time),
load_completed_idle_timeout => INTERVAL '10' SECOND) AS r
ON o.currency = r.currency;
{code}
The probe side runs, but nothing is emitted yet. Then write the initial build
state:
{code:bash}
cat > /tmp/flip579/build/001.json <<'EOF'
{"before":null,"after":{"currency":"A","rate":1.0,"update_time":"2026-01-01
10:00:00"},"op":"c"}
{"before":null,"after":{"currency":"B","rate":2.0,"update_time":"2026-01-01
10:00:00"},"op":"c"}
{"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-01-01
11:00:00"},"op":"c"}
EOF
{code}
Expected:
* Before the file is written, {{currentPhase}} = 0 and
{{numProbeSideRecordsBuffered}} grows by about 1 per second. Nothing is printed.
* About 10 seconds after the last build-side watermark advance, the join flips:
{{currentPhase}} = 1 and {{numProbeSideRecordsBuffered}} drops to 0.
* All buffered orders are printed at once: A orders with rate 1.0, B orders
with rate 2.0. C orders are not printed (inner join), and
{{numUnmatchedProbeRecords}} counts them.
* New orders are printed immediately afterwards.
* {{currentBuildSideWatermark}} = {{1767265200000}} ({{2026-01-01 11:00:00}}
UTC). {{currentProbeSideWatermark}} is close to the current time in epoch
milliseconds. {{maxJoinFanOut}} = 1.
h3. 2. Stuck in the load phase
Cancel the job and empty the build directory ({{rm /tmp/flip579/build/\*}}).
Submit the same query *without* {{load_completed_idle_timeout}}, and drop
{{001.json}} again. The default {{load_completed_time}} is the wall-clock time
at query compilation, so the build-side watermark (January 2026) never reaches
it.
Expected: {{currentPhase}} stays 0, {{numProbeSideRecordsBuffered}} keeps
growing, there is no output, and {{currentBuildSideWatermark}} stays at
{{1767265200000}}.
Then fix it with an explicit load completion time. Cancel the job, empty the
build directory, and submit:
{code:sql}
INSERT INTO out_print
SELECT o.order_id, o.currency, r.rate
FROM orders AS o
JOIN LATERAL SNAPSHOT(input => TABLE rates, on_time => DESCRIPTOR(update_time),
load_completed_time => CAST(TIMESTAMP '2026-01-02 00:00:00' AS
TIMESTAMP_LTZ(3))) AS r
ON o.currency = r.currency;
{code}
Drop {{001.json}} (its dummy {{Z}} row moves the watermark to 2026-01-01 11:00,
which is below the load completion time). The join stays in the load phase.
Then drop a file that moves the watermark past the load completion time:
{code:bash}
cat > /tmp/flip579/build/002.json <<'EOF'
{"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-01-02
00:00:00"},"op":"c"}
EOF
{code}
Expected: the join flips as soon as the watermark reaches 2026-01-02 00:00:00
({{currentBuildSideWatermark}} = {{1767312000000}}), {{currentPhase}} = 1,
buffered orders are printed.
h3. 3. Build-side updates, deletes and LEFT JOIN
Cancel the job, empty the build directory and submit the section 1 query as
{{LEFT JOIN LATERAL SNAPSHOT(...)}} (still with the idle timeout). Drop
{{001.json}} from section 1 and wait for the flip. A orders have rate 1.0, B
orders 2.0, and C orders are printed with a {{NULL}} rate. Then drop:
{code:bash}
cat > /tmp/flip579/build/003.json <<'EOF'
{"before":{"currency":"A","rate":1.0,"update_time":"2026-01-01
10:00:00"},"after":{"currency":"A","rate":1.5,"update_time":"2026-02-01
10:00:00"},"op":"u"}
{"before":{"currency":"B","rate":2.0,"update_time":"2026-01-01
10:00:00"},"after":null,"op":"d"}
{"before":null,"after":{"currency":"C","rate":3.0,"update_time":"2026-02-01
10:00:00"},"op":"c"}
{"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-02-02
10:00:00"},"op":"c"}
EOF
{code}
Expected after a few seconds:
* New A orders have rate 1.5. Rate 1.0 is never mixed with 1.5 in an
inconsistent way: the update becomes visible atomically.
* New B orders have a {{NULL}} rate (the row was deleted).
* New C orders have rate 3.0.
* Already printed rows are never changed. The result is append-only.
* {{numUnmatchedBuildRetractions}} stays 0, because all retractions hit
existing rows.
* Note: {{numUnmatchedProbeRecords}} stays 0 for a LEFT JOIN, although
unmatched rows are emitted null-padded. It only counts unmatched rows of INNER
joins (the docs describe it more generally).
h3. 4. Planning-time validation
Create the extra tables and views (add to the SQL Client session):
{code:sql}
CREATE TABLE rates_nowm (currency STRING, rate DOUBLE, update_time TIMESTAMP(3))
WITH
('connector'='filesystem','path'='/tmp/flip579/build','format'='debezium-json','source.monitor-interval'='2s');
CREATE VIEW rates_rand AS SELECT currency, rate, RAND() AS r, update_time FROM
rates;
CREATE VIEW probe_agg AS SELECT currency, COUNT(*) AS c FROM orders GROUP BY
currency;
{code}
Each of the following statements must be rejected with the quoted error. The
full statement is {{EXPLAIN SELECT o.order_id FROM orders AS o <fragment> ON
o.currency = r.currency;}}, with the {{<fragment>}} from the first column (in
the {{probe_agg}} row the fragment replaces {{orders AS o}} as well, and the
standalone {{SNAPSHOT}} row is a complete statement without {{ON}}). For
{{RIGHT}}/{{FULL}} use the arguments {{(input => TABLE rates, on_time =>
DESCRIPTOR(update_time))}}. A failing statement aborts a {{\-f}} script, so
paste the statements one at a time into the interactive client.
|| Statement || Expected error ||
| {{JOIN LATERAL SNAPSHOT(input => TABLE rates_nowm, on_time =>
DESCRIPTOR(update_time)) AS r}} | {{Argument 'on_time' of SNAPSHOT must
reference a rowtime attribute (a column with a watermark), but column
'update_time' is not one.}} |
| {{JOIN LATERAL SNAPSHOT(input => TABLE rates) AS r}} (no {{on_time}}) |
{{LATERAL SNAPSHOT requires the 'on_time' argument to identify the build-side
rowtime attribute.}} |
| {{on_time => DESCRIPTOR(rate)}} | {{Argument 'on_time' of SNAPSHOT must
reference a TIMESTAMP or TIMESTAMP_LTZ column (up to precision 3), but column
'rate' has type 'DOUBLE'.}} |
| {{FROM probe_agg AS o JOIN LATERAL SNAPSHOT(input => TABLE rates, on_time =>
DESCRIPTOR(update_time)) AS r}} | {{The probe (left) input of LATERAL SNAPSHOT
join doesn't support consuming update changes ...}} |
| {{RIGHT JOIN LATERAL SNAPSHOT(...)}} / {{FULL JOIN LATERAL SNAPSHOT(...)}} |
{{LATERAL SNAPSHOT join only supports INNER JOIN and LEFT OUTER JOIN, but was
RIGHT JOIN.}} (and {{FULL JOIN}}) |
| {{SELECT * FROM TABLE(SNAPSHOT(input => TABLE rates, on_time =>
DESCRIPTOR(update_time)))}} | {{The SNAPSHOT function can only be used as the
build side (right-hand side) of a LATERAL join. ...}} |
Non-deterministic build columns (FLINK-40890). The check is part of the
non-deterministic update handling, so enable it first:
{code:sql}
SET 'table.optimizer.non-deterministic-update.strategy' = 'TRY_RESOLVE';
EXPLAIN SELECT o.order_id, r.rate, r.r FROM orders AS o
JOIN LATERAL SNAPSHOT(input => TABLE rates_rand, on_time =>
DESCRIPTOR(update_time)) AS r ON o.currency = r.currency;
-- TableException: The column(s): r(generated by non-deterministic function:
RAND ) can not satisfy the determinism requirement ...
RESET 'table.optimizer.non-deterministic-update.strategy';
{code}
The same statement is accepted with the default strategy ({{IGNORE}}), and also
with {{TRY_RESOLVE}} if the non-deterministic column is not selected.
The {{TABLE}} keyword in {{LATERAL}} calls is optional. Verify that {{JOIN
LATERAL SNAPSHOT(...)}}, {{JOIN LATERAL TABLE(SNAPSHOT(...))}} and {{CROSS JOIN
LATERAL SNAPSHOT(...) ... WHERE o.currency = r.currency}} all plan, and that
{{EXPLAIN}} shows a {{LateralSnapshotJoin}} node.
h3. 5. Join predicates and parallelism
Run the section 1 query as an {{INSERT INTO}} with {{SET 'parallelism.default'
= '2';}} and different {{ON}} clauses, and check the parallelism of the
{{LateralSnapshotJoin}} vertex in the Web UI. At parallelism 2, one build-side
source subtask never reads data, so aggregated values of
{{currentBuildSideWatermark}} can show Long.MIN_VALUE. This is expected, so
look at the metric per subtask. Cancel each job before submitting the next one,
and run {{RESET 'parallelism.default';}} when you are done.
* {{ON o.currency = r.currency AND o.order_id >= 0}} (equi and non-equi mixed):
the join runs with parallelism 2.
* {{ON o.currency <> r.currency}} (no equality predicate): the join runs with
parallelism 1, while the sources run with 2. After flipping, each order is
joined with all non-matching currencies, so {{maxJoinFanOut}} > 1 and
{{avgJoinFanOut}} shows the average (the dummy {{Z}} row also matches).
h3. 6. State TTL
Run the section 1 query with an additional {{state_ttl => INTERVAL '20'
SECOND}}, and drop {{001.json}}. After the flip and the TTL ({{t ~ 40-50 s}}):
* {{numStateTtlEvictions}} = 1. The key {{Z}} was never accessed by a probe row
and is evicted.
* The keys {{A}} and {{B}} are accessed by probe rows all the time, so they are
kept and orders are still matched with their rates.
* {{numStateTtlEvictions}} stays 0 during the load phase.
h3. 7. Savepoint and restore
*Join phase.* Run the job from section 6 (or section 1) until {{currentPhase}}
= 1, then:
{code:bash}
./bin/flink stop <job-id>
# prints: Savepoint completed. Path: file:/tmp/flip579/savepoints/savepoint-...
{code}
In the SQL Client, run {{SET 'execution.state-recovery.path' = '<savepoint
path>';}} and submit the same {{INSERT INTO}} again. Then run {{RESET
'execution.state-recovery.path';}}, otherwise every later submission in this
session restores from that savepoint. Expected:
* The join is in the join phase right away ({{currentPhase}} = 1), without any
new build-side data.
* A and B orders are still matched. The build-side state was restored.
* The metric values are not restored. Counters start at 0, and
{{currentBuildSideWatermark}} is {{\-9.2E18}} (Long.MIN_VALUE) until the next
build-side watermark arrives.
*Load phase.* Cancel the job and empty the build directory. Submit the second
query of section 2 (with {{load_completed_time}}) without build data. Let it
buffer about 30 probe rows, then stop it with {{./bin/flink stop}}. Restore it
with {{execution.state-recovery.path}} (and {{RESET}} it afterwards), and
verify that {{currentPhase}} = 0 after the restore. Then drop {{001.json}} with
the {{Z}} row at {{2026-01-03 00:00:00}}. Expected: the join flips, and the
output contains the orders that were buffered *before* the savepoint
({{order_id}} starting at 1, with rates for A and B). The
{{numProbeSideRecordsBuffered}} gauge only counts rows seen since the restore,
so check the output instead.
h3. 8. Batch mode
{code:bash}
mkdir -p /tmp/flip579/orders_b /tmp/flip579/rates_b
printf '1,A\n2,B\n3,C\n4,A\n' > /tmp/flip579/orders_b/o.csv
printf 'A,1.0\nB,2.0\nA,1.5\n' > /tmp/flip579/rates_b/r.csv
{code}
{code:sql}
SET 'execution.runtime-mode' = 'batch';
CREATE TABLE orders_b (order_id BIGINT, currency STRING) WITH ('connector' =
'filesystem', 'path' = '/tmp/flip579/orders_b', 'format' = 'csv');
CREATE TABLE rates_b (currency STRING, rate DOUBLE) WITH ('connector' =
'filesystem', 'path' = '/tmp/flip579/rates_b', 'format' = 'csv');
SELECT o.order_id, o.currency, r.rate
FROM orders_b AS o
LEFT JOIN LATERAL SNAPSHOT(input => TABLE rates_b, state_ttl => INTERVAL '1'
HOUR) AS r
ON o.currency = r.currency ORDER BY o.order_id, r.rate;
RESET 'execution.runtime-mode';
{code}
Expected: the query finishes without watermarks and without {{on_time}}.
{{state_ttl}} is accepted and ignored. The result is the regular left join
against the complete build side (the SQL Client renders it as a table and
prints {{<NULL>}}; the rows are the same):
{noformat}
1 | A | 1.0
1 | A | 1.5
2 | B | 2.0
3 | C | NULL
4 | A | 1.0
4 | A | 1.5
{noformat}
Repeated runs return the same result. {{EXPLAIN}} of the inner join variant
shows a regular join ({{HashJoin}}/{{AdaptiveJoin}}) and no
{{LateralSnapshotJoin}}.
h3. 9. Table API
Save the following as {{LsjTableApi.java}} in {{build-target}} and run it from
there with {{java -cp "lib/\*" LsjTableApi.java}} from {{build-target}} (Java
17 single-file launch). The columns of the two tables must have different
names, hence the {{r_}} prefix.
{code:java}
import static org.apache.flink.table.api.Expressions.*;
import org.apache.flink.table.api.*;
public class LsjTableApi {
public static void main(String[] args) throws Exception {
TableEnvironment env =
TableEnvironment.create(EnvironmentSettings.inStreamingMode());
env.executeSql(
"CREATE TABLE rates (r_currency STRING, r_rate DOUBLE,
update_time TIMESTAMP(3),"
+ " WATERMARK FOR update_time AS update_time) WITH ("
+
"'connector'='filesystem','path'='/tmp/flip579/build','format'='debezium-json')");
env.executeSql(
"CREATE TABLE orders (order_id BIGINT, currency STRING, ts AS
LOCALTIMESTAMP,"
+ " WATERMARK FOR ts AS ts - INTERVAL '1' SECOND) WITH
('connector'='datagen')");
Table orders = env.from("orders");
Table rates = env.from("rates");
Table inner = orders.joinLateral(
call("SNAPSHOT", rates.asArgument("input"),
descriptor("update_time").asArgument("on_time")),
$("currency").isEqual($("r_currency")));
System.out.println(inner.explain());
Table left = orders.leftOuterJoinLateral(
call("SNAPSHOT", rates.asArgument("input"),
descriptor("update_time").asArgument("on_time")),
$("currency").isEqual($("r_currency")));
System.out.println(left.explain());
}
}
{code}
Expected: both plans contain a {{LateralSnapshotJoin(joinType=[InnerJoin])}}
and {{LateralSnapshotJoin(joinType=[LeftOuterJoin])}} node respectively.
Optionally replace {{explain()}} with {{executeInsert}} into a {{print}} table
and compare the behavior to sections 1 and 3 (the Table API job runs in a local
MiniCluster, so there is no Web UI).
h3. Exploratory
* Real sources: a probe side from Kafka and a build side from an
{{upsert-kafka}} topic, with watermark alignment
({{scan.watermark.alignment.group}}) on both sources and
{{table.exec.source.idle-timeout}}.
* A large build-side backlog and a probe-side start offset ({{/\*+
OPTIONS('scan.startup.mode'=...) \*/}}) to see the amount of buffered probe
rows and the output spike at the flip.
* Failure recovery in the join phase (kill a TaskManager). Results after
recovery may differ from before, but must not contain half-applied update pairs.
* NULL join keys, composite keys, a view or CTE as build side, and
{{state_ttl}} without an equality predicate (the whole build side is evicted at
once when idle).
* RocksDB state backend ({{state.backend.type: rocksdb}}) for sections 1, 3 and
7.
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.
was:
[FLIP-579|https://cwiki.apache.org/confluence/display/FLINK/FLIP-579%3A+LATERAL+SNAPSHOT+Join]
adds {{LATERAL SNAPSHOT}} join, a stream enrichment join. It joins an
append-only probe table with the *current* state of an updating build table. A
load phase first fills the build-side state and buffers probe rows. Afterwards,
every probe row is joined immediately, without waiting for build-side
watermarks. Neither side needs a primary key. The result is non-deterministic
in streaming mode and deterministic in batch mode.
Docs: [LATERAL SNAPSHOT
Join|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/sql/reference/queries/joins/#lateral-snapshot-join]
and [Lateral Snapshot Join (Table
API)|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/dev/table/tableapi/#lateral-snapshot-join].
Planner, operator and restore tests are covered by unit and IT tests. This
ticket is about the end-to-end path on a real cluster: phases, metrics,
savepoints, batch mode and the Table API.
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/flip579/build /tmp/flip579/savepoints /tmp/flip579/checkpoints
{code}
In {{conf/config.yaml}}, change {{numberOfTaskSlots: 1}} to {{4}}, and append:
{code:yaml}
execution.checkpointing.dir: file:///tmp/flip579/checkpoints
execution.checkpointing.savepoint-dir: file:///tmp/flip579/savepoints
execution.checkpointing.interval: 10s
{code}
Start the cluster with {{./bin/start-cluster.sh}}. Save the following as
{{/tmp/flip579/ddl.sql}} and start the SQL Client with {{./bin/sql-client.sh -i
/tmp/flip579/ddl.sql}}. Run every query in the sections below in that session.
Queries that start with {{INSERT INTO}} run detached on the cluster, and you
can see their result in {{log/flink-<user>-taskexecutor-<n>-<host>.out}} (the
{{print}} sink). Use {{tail -f}} on it, and do not delete or truncate the file
while the TaskManager is running (output is lost until the cluster restarts).
Note the line count instead.
The SQL Client only loads {{ddl.sql}} via {{-i}} on start, and the tables and
views created in section 4 live only in the session. If you restart the client,
pass {{-i}} again and recreate them.
The build side is a directory of {{debezium-json}} files that the filesystem
source monitors. A file you drop into the directory is read as build-side
changes, including updates and deletes. The probe side is a datagen source with
one order per second and the currency cycling A, B, C.
{code:sql}
SET 'table.local-time-zone' = 'UTC';
SET 'sql-client.execution.result-mode' = 'tableau';
CREATE TABLE rates (
currency STRING, rate DOUBLE, update_time TIMESTAMP(3),
WATERMARK FOR update_time AS update_time
) WITH (
'connector' = 'filesystem', 'path' = '/tmp/flip579/build',
'format' = 'debezium-json', 'source.monitor-interval' = '2s');
CREATE TABLE orders (
order_id BIGINT,
currency AS CASE order_id % 3 WHEN 0 THEN 'A' WHEN 1 THEN 'B' ELSE 'C' END,
ts AS LOCALTIMESTAMP,
WATERMARK FOR ts AS ts - 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 out_print (order_id BIGINT, currency STRING, rate DOUBLE) WITH
('connector' = 'print');
{code}
{{SET}} statements stay active for the rest of the SQL Client session. The
sections below tell you when to {{RESET}} a setting. Alternatively, restart the
SQL Client between sections.
The operator metrics are visible in the Web UI ({{http://localhost:8081}}):
open the job, click the {{LateralSnapshotJoin}} vertex and go to the Metrics
tab (the metric names are prefixed with {{LateralSnapshotJoin[n].}}). The same
values are available via REST, e.g. {{GET
/jobs/<job-id>/vertices/<vertex-id>/subtasks/metrics?get=LateralSnapshotJoin[9].currentPhase}}.
The operator number may differ. Metric values refresh only about every 10
seconds, so a check such as "immediately" or "after a few seconds" can look
stale for that long.
*Build-side changes are applied when the build-side watermark passes their row
time* (they are buffered until then, see the FLIP). A build row is therefore
invisible to the join until a later build-side watermark arrives. All build
files below end with a row for a dummy key {{Z}} with a later {{update_time}}
for this reason. It pushes the watermark, and no probe row matches it. Without
it, the last rows of a file stay invisible. In real pipelines the next
build-side record does this.
h3. 1. Load phase and flip via idle timeout
Start with an empty {{/tmp/flip579/build}} and submit:
{code:sql}
INSERT INTO out_print
SELECT o.order_id, o.currency, r.rate
FROM orders AS o
JOIN LATERAL SNAPSHOT(
input => TABLE rates,
on_time => DESCRIPTOR(update_time),
load_completed_idle_timeout => INTERVAL '10' SECOND) AS r
ON o.currency = r.currency;
{code}
The probe side runs, but nothing is emitted yet. Then write the initial build
state:
{code:bash}
cat > /tmp/flip579/build/001.json <<'EOF'
{"before":null,"after":{"currency":"A","rate":1.0,"update_time":"2026-01-01
10:00:00"},"op":"c"}
{"before":null,"after":{"currency":"B","rate":2.0,"update_time":"2026-01-01
10:00:00"},"op":"c"}
{"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-01-01
11:00:00"},"op":"c"}
EOF
{code}
Expected:
* Before the file is written, {{currentPhase}} = 0 and
{{numProbeSideRecordsBuffered}} grows by about 1 per second. Nothing is printed.
* About 10 seconds after the last build-side watermark advance, the join flips:
{{currentPhase}} = 1 and {{numProbeSideRecordsBuffered}} drops to 0.
* All buffered orders are printed at once: A orders with rate 1.0, B orders
with rate 2.0. C orders are not printed (inner join), and
{{numUnmatchedProbeRecords}} counts them.
* New orders are printed immediately afterwards.
* {{currentBuildSideWatermark}} = {{1767265200000}} ({{2026-01-01 11:00:00}}
UTC). {{currentProbeSideWatermark}} is close to the current time in epoch
milliseconds. {{maxJoinFanOut}} = 1.
h3. 2. Stuck in the load phase
Cancel the job and empty the build directory ({{rm /tmp/flip579/build/*}}).
Submit the same query *without* {{load_completed_idle_timeout}}, and drop
{{001.json}} again. The default {{load_completed_time}} is the wall-clock time
at query compilation, so the build-side watermark (January 2026) never reaches
it.
Expected: {{currentPhase}} stays 0, {{numProbeSideRecordsBuffered}} keeps
growing, there is no output, and {{currentBuildSideWatermark}} stays at
{{1767265200000}}.
Then fix it with an explicit load completion time. Cancel the job, empty the
build directory, and submit:
{code:sql}
INSERT INTO out_print
SELECT o.order_id, o.currency, r.rate
FROM orders AS o
JOIN LATERAL SNAPSHOT(input => TABLE rates, on_time => DESCRIPTOR(update_time),
load_completed_time => CAST(TIMESTAMP '2026-01-02 00:00:00' AS
TIMESTAMP_LTZ(3))) AS r
ON o.currency = r.currency;
{code}
Drop {{001.json}} (its dummy {{Z}} row moves the watermark to 2026-01-01 11:00,
which is below the load completion time). The join stays in the load phase.
Then drop a file that moves the watermark past the load completion time:
{code:bash}
cat > /tmp/flip579/build/002.json <<'EOF'
{"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-01-02
00:00:00"},"op":"c"}
EOF
{code}
Expected: the join flips as soon as the watermark reaches 2026-01-02 00:00:00
({{currentBuildSideWatermark}} = {{1767312000000}}), {{currentPhase}} = 1,
buffered orders are printed.
h3. 3. Build-side updates, deletes and LEFT JOIN
Cancel the job, empty the build directory and submit the section 1 query as
{{LEFT JOIN LATERAL SNAPSHOT(...)}} (still with the idle timeout). Drop
{{001.json}} from section 1 and wait for the flip. A orders have rate 1.0, B
orders 2.0, and C orders are printed with a {{NULL}} rate. Then drop:
{code:bash}
cat > /tmp/flip579/build/003.json <<'EOF'
{"before":{"currency":"A","rate":1.0,"update_time":"2026-01-01
10:00:00"},"after":{"currency":"A","rate":1.5,"update_time":"2026-02-01
10:00:00"},"op":"u"}
{"before":{"currency":"B","rate":2.0,"update_time":"2026-01-01
10:00:00"},"after":null,"op":"d"}
{"before":null,"after":{"currency":"C","rate":3.0,"update_time":"2026-02-01
10:00:00"},"op":"c"}
{"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-02-02
10:00:00"},"op":"c"}
EOF
{code}
Expected after a few seconds:
* New A orders have rate 1.5. Rate 1.0 is never mixed with 1.5 in an
inconsistent way: the update becomes visible atomically.
* New B orders have a {{NULL}} rate (the row was deleted).
* New C orders have rate 3.0.
* Already printed rows are never changed. The result is append-only.
* {{numUnmatchedBuildRetractions}} stays 0, because all retractions hit
existing rows.
* Note: {{numUnmatchedProbeRecords}} stays 0 for a LEFT JOIN, although
unmatched rows are emitted null-padded. It only counts unmatched rows of INNER
joins (the docs describe it more generally).
h3. 4. Planning-time validation
Create the extra tables and views (add to the SQL Client session):
{code:sql}
CREATE TABLE rates_nowm (currency STRING, rate DOUBLE, update_time TIMESTAMP(3))
WITH
('connector'='filesystem','path'='/tmp/flip579/build','format'='debezium-json','source.monitor-interval'='2s');
CREATE VIEW rates_rand AS SELECT currency, rate, RAND() AS r, update_time FROM
rates;
CREATE VIEW probe_agg AS SELECT currency, COUNT(*) AS c FROM orders GROUP BY
currency;
{code}
Each of the following statements must be rejected with the quoted error. The
full statement is {{EXPLAIN SELECT o.order_id FROM orders AS o <fragment> ON
o.currency = r.currency;}}, with the {{<fragment>}} from the first column (in
the {{probe_agg}} row the fragment replaces {{orders AS o}} as well, and the
standalone {{SNAPSHOT}} row is a complete statement without {{ON}}). For
{{RIGHT}}/{{FULL}} use the arguments {{(input => TABLE rates, on_time =>
DESCRIPTOR(update_time))}}. A failing statement aborts a {{-f}} script, so
paste the statements one at a time into the interactive client.
|| Statement || Expected error ||
| {{JOIN LATERAL SNAPSHOT(input => TABLE rates_nowm, on_time =>
DESCRIPTOR(update_time)) AS r}} | {{Argument 'on_time' of SNAPSHOT must
reference a rowtime attribute (a column with a watermark), but column
'update_time' is not one.}} |
| {{JOIN LATERAL SNAPSHOT(input => TABLE rates) AS r}} (no {{on_time}}) |
{{LATERAL SNAPSHOT requires the 'on_time' argument to identify the build-side
rowtime attribute.}} |
| {{on_time => DESCRIPTOR(rate)}} | {{Argument 'on_time' of SNAPSHOT must
reference a TIMESTAMP or TIMESTAMP_LTZ column (up to precision 3), but column
'rate' has type 'DOUBLE'.}} |
| {{FROM probe_agg AS o JOIN LATERAL SNAPSHOT(input => TABLE rates, on_time =>
DESCRIPTOR(update_time)) AS r}} | {{The probe (left) input of LATERAL SNAPSHOT
join doesn't support consuming update changes ...}} |
| {{RIGHT JOIN LATERAL SNAPSHOT(...)}} / {{FULL JOIN LATERAL SNAPSHOT(...)}} |
{{LATERAL SNAPSHOT join only supports INNER JOIN and LEFT OUTER JOIN, but was
RIGHT JOIN.}} (and {{FULL JOIN}}) |
| {{SELECT * FROM TABLE(SNAPSHOT(input => TABLE rates, on_time =>
DESCRIPTOR(update_time)))}} | {{The SNAPSHOT function can only be used as the
build side (right-hand side) of a LATERAL join. ...}} |
Non-deterministic build columns (FLINK-40890). The check is part of the
non-deterministic update handling, so enable it first:
{code:sql}
SET 'table.optimizer.non-deterministic-update.strategy' = 'TRY_RESOLVE';
EXPLAIN SELECT o.order_id, r.rate, r.r FROM orders AS o
JOIN LATERAL SNAPSHOT(input => TABLE rates_rand, on_time =>
DESCRIPTOR(update_time)) AS r ON o.currency = r.currency;
-- TableException: The column(s): r(generated by non-deterministic function:
RAND ) can not satisfy the determinism requirement ...
RESET 'table.optimizer.non-deterministic-update.strategy';
{code}
The same statement is accepted with the default strategy ({{IGNORE}}), and also
with {{TRY_RESOLVE}} if the non-deterministic column is not selected.
The {{TABLE}} keyword in {{LATERAL}} calls is optional. Verify that {{JOIN
LATERAL SNAPSHOT(...)}}, {{JOIN LATERAL TABLE(SNAPSHOT(...))}} and {{CROSS JOIN
LATERAL SNAPSHOT(...) ... WHERE o.currency = r.currency}} all plan, and that
{{EXPLAIN}} shows a {{LateralSnapshotJoin}} node.
h3. 5. Join predicates and parallelism
Run the section 1 query as an {{INSERT INTO}} with {{SET 'parallelism.default'
= '2';}} and different {{ON}} clauses, and check the parallelism of the
{{LateralSnapshotJoin}} vertex in the Web UI. At parallelism 2, one build-side
source subtask never reads data, so aggregated values of
{{currentBuildSideWatermark}} can show Long.MIN_VALUE. This is expected, so
look at the metric per subtask. Cancel each job before submitting the next one,
and run {{RESET 'parallelism.default';}} when you are done.
* {{ON o.currency = r.currency AND o.order_id >= 0}} (equi and non-equi mixed):
the join runs with parallelism 2.
* {{ON o.currency <> r.currency}} (no equality predicate): the join runs with
parallelism 1, while the sources run with 2. After flipping, each order is
joined with all non-matching currencies, so {{maxJoinFanOut}} > 1 and
{{avgJoinFanOut}} shows the average (the dummy {{Z}} row also matches).
h3. 6. State TTL
Run the section 1 query with an additional {{state_ttl => INTERVAL '20'
SECOND}}, and drop {{001.json}}. After the flip and the TTL ({{t ~ 40-50 s}}):
* {{numStateTtlEvictions}} = 1. The key {{Z}} was never accessed by a probe row
and is evicted.
* The keys {{A}} and {{B}} are accessed by probe rows all the time, so they are
kept and orders are still matched with their rates.
* {{numStateTtlEvictions}} stays 0 during the load phase.
h3. 7. Savepoint and restore
*Join phase.* Run the job from section 6 (or section 1) until {{currentPhase}}
= 1, then:
{code:bash}
./bin/flink stop <job-id>
# prints: Savepoint completed. Path: file:/tmp/flip579/savepoints/savepoint-...
{code}
In the SQL Client, run {{SET 'execution.state-recovery.path' = '<savepoint
path>';}} and submit the same {{INSERT INTO}} again. Then run {{RESET
'execution.state-recovery.path';}}, otherwise every later submission in this
session restores from that savepoint. Expected:
* The join is in the join phase right away ({{currentPhase}} = 1), without any
new build-side data.
* A and B orders are still matched. The build-side state was restored.
* The metric values are not restored. Counters start at 0, and
{{currentBuildSideWatermark}} is {{-9.2E18}} (Long.MIN_VALUE) until the next
build-side watermark arrives.
*Load phase.* Cancel the job and empty the build directory. Submit the second
query of section 2 (with {{load_completed_time}}) without build data. Let it
buffer about 30 probe rows, then stop it with {{./bin/flink stop}}. Restore it
with {{execution.state-recovery.path}} (and {{RESET}} it afterwards), and
verify that {{currentPhase}} = 0 after the restore. Then drop {{001.json}} with
the {{Z}} row at {{2026-01-03 00:00:00}}. Expected: the join flips, and the
output contains the orders that were buffered *before* the savepoint
({{order_id}} starting at 1, with rates for A and B). The
{{numProbeSideRecordsBuffered}} gauge only counts rows seen since the restore,
so check the output instead.
h3. 8. Batch mode
{code:bash}
mkdir -p /tmp/flip579/orders_b /tmp/flip579/rates_b
printf '1,A\n2,B\n3,C\n4,A\n' > /tmp/flip579/orders_b/o.csv
printf 'A,1.0\nB,2.0\nA,1.5\n' > /tmp/flip579/rates_b/r.csv
{code}
{code:sql}
SET 'execution.runtime-mode' = 'batch';
CREATE TABLE orders_b (order_id BIGINT, currency STRING) WITH ('connector' =
'filesystem', 'path' = '/tmp/flip579/orders_b', 'format' = 'csv');
CREATE TABLE rates_b (currency STRING, rate DOUBLE) WITH ('connector' =
'filesystem', 'path' = '/tmp/flip579/rates_b', 'format' = 'csv');
SELECT o.order_id, o.currency, r.rate
FROM orders_b AS o
LEFT JOIN LATERAL SNAPSHOT(input => TABLE rates_b, state_ttl => INTERVAL '1'
HOUR) AS r
ON o.currency = r.currency ORDER BY o.order_id, r.rate;
RESET 'execution.runtime-mode';
{code}
Expected: the query finishes without watermarks and without {{on_time}}.
{{state_ttl}} is accepted and ignored. The result is the regular left join
against the complete build side (the SQL Client renders it as a table and
prints {{<NULL>}}; the rows are the same):
{noformat}
1 | A | 1.0
1 | A | 1.5
2 | B | 2.0
3 | C | NULL
4 | A | 1.0
4 | A | 1.5
{noformat}
Repeated runs return the same result. {{EXPLAIN}} of the inner join variant
shows a regular join ({{HashJoin}}/{{AdaptiveJoin}}) and no
{{LateralSnapshotJoin}}.
h3. 9. Table API
Save the following as {{LsjTableApi.java}} in {{build-target}} and run it from
there with {{java -cp "lib/*" LsjTableApi.java}} from {{build-target}} (Java 17
single-file launch). The columns of the two tables must have different names,
hence the {{r_}} prefix.
{code:java}
import static org.apache.flink.table.api.Expressions.*;
import org.apache.flink.table.api.*;
public class LsjTableApi {
public static void main(String[] args) throws Exception {
TableEnvironment env =
TableEnvironment.create(EnvironmentSettings.inStreamingMode());
env.executeSql(
"CREATE TABLE rates (r_currency STRING, r_rate DOUBLE,
update_time TIMESTAMP(3),"
+ " WATERMARK FOR update_time AS update_time) WITH ("
+
"'connector'='filesystem','path'='/tmp/flip579/build','format'='debezium-json')");
env.executeSql(
"CREATE TABLE orders (order_id BIGINT, currency STRING, ts AS
LOCALTIMESTAMP,"
+ " WATERMARK FOR ts AS ts - INTERVAL '1' SECOND) WITH
('connector'='datagen')");
Table orders = env.from("orders");
Table rates = env.from("rates");
Table inner = orders.joinLateral(
call("SNAPSHOT", rates.asArgument("input"),
descriptor("update_time").asArgument("on_time")),
$("currency").isEqual($("r_currency")));
System.out.println(inner.explain());
Table left = orders.leftOuterJoinLateral(
call("SNAPSHOT", rates.asArgument("input"),
descriptor("update_time").asArgument("on_time")),
$("currency").isEqual($("r_currency")));
System.out.println(left.explain());
}
}
{code}
Expected: both plans contain a {{LateralSnapshotJoin(joinType=[InnerJoin])}}
and {{LateralSnapshotJoin(joinType=[LeftOuterJoin])}} node respectively.
Optionally replace {{explain()}} with {{executeInsert}} into a {{print}} table
and compare the behavior to sections 1 and 3 (the Table API job runs in a local
MiniCluster, so there is no Web UI).
h3. Exploratory
* Real sources: a probe side from Kafka and a build side from an
{{upsert-kafka}} topic, with watermark alignment
({{scan.watermark.alignment.group}}) on both sources and
{{table.exec.source.idle-timeout}}.
* A large build-side backlog and a probe-side start offset ({{/*+
OPTIONS('scan.startup.mode'=...) */}}) to see the amount of buffered probe rows
and the output spike at the flip.
* Failure recovery in the join phase (kill a TaskManager). Results after
recovery may differ from before, but must not contain half-applied update pairs.
* NULL join keys, composite keys, a view or CTE as build side, and
{{state_ttl}} without an equality predicate (the whole build side is evicted at
once when idle).
* RocksDB state backend ({{state.backend.type: rocksdb}}) for sections 1, 3 and
7.
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.
> Release Testing: Verify FLIP-579: LATERAL SNAPSHOT Join
> -------------------------------------------------------
>
> Key: FLINK-41368
> URL: https://issues.apache.org/jira/browse/FLINK-41368
> Project: Flink
> Issue Type: Sub-task
> Components: Release System, Tests
> Affects Versions: 2.4.0
> Reporter: Fabian Hueske
> Priority: Major
> Fix For: 2.4.0
>
>
> [FLIP-579|https://cwiki.apache.org/confluence/display/FLINK/FLIP-579%3A+LATERAL+SNAPSHOT+Join]
> adds {{LATERAL SNAPSHOT}} join, a stream enrichment join. It joins an
> append-only probe table with the *current* state of an updating build table.
> A load phase first fills the build-side state and buffers probe rows.
> Afterwards, every probe row is joined immediately, without waiting for
> build-side watermarks. Neither side needs a primary key. The result is
> non-deterministic in streaming mode and deterministic in batch mode.
> Docs: [LATERAL SNAPSHOT
> Join|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/sql/reference/queries/joins/#lateral-snapshot-join]
> and [Lateral Snapshot Join (Table
> API)|https://nightlies.apache.org/flink/flink-docs-release-2.4/docs/dev/table/tableapi/#lateral-snapshot-join].
> Planner, operator and restore tests are covered by unit and IT tests. This
> ticket is about the end-to-end path on a real cluster: phases, metrics,
> savepoints, batch mode and the Table API.
> 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/flip579/build /tmp/flip579/savepoints /tmp/flip579/checkpoints
> {code}
> In {{conf/config.yaml}}, change {{numberOfTaskSlots: 1}} to {{4}}, and append:
> {code:yaml}
> execution.checkpointing.dir: file:///tmp/flip579/checkpoints
> execution.checkpointing.savepoint-dir: file:///tmp/flip579/savepoints
> execution.checkpointing.interval: 10s
> {code}
> Start the cluster with {{./bin/start-cluster.sh}}. Save the following as
> {{/tmp/flip579/ddl.sql}} and start the SQL Client with {{./bin/sql-client.sh
> \-i /tmp/flip579/ddl.sql}}. Run every query in the sections below in that
> session. Queries that start with {{INSERT INTO}} run detached on the cluster,
> and you can see their result in
> {{log/flink-<user>-taskexecutor-<n>-<host>.out}} (the {{print}} sink). Use
> {{tail \-f}} on it, and do not delete or truncate the file while the
> TaskManager is running (output is lost until the cluster restarts). Note the
> line count instead.
> The SQL Client only loads {{ddl.sql}} via {{\-i}} on start, and the tables
> and views created in section 4 live only in the session. If you restart the
> client, pass {{\-i}} again and recreate them.
> The build side is a directory of {{debezium-json}} files that the filesystem
> source monitors. A file you drop into the directory is read as build-side
> changes, including updates and deletes. The probe side is a datagen source
> with one order per second and the currency cycling A, B, C.
> {code:sql}
> SET 'table.local-time-zone' = 'UTC';
> SET 'sql-client.execution.result-mode' = 'tableau';
> CREATE TABLE rates (
> currency STRING, rate DOUBLE, update_time TIMESTAMP(3),
> WATERMARK FOR update_time AS update_time
> ) WITH (
> 'connector' = 'filesystem', 'path' = '/tmp/flip579/build',
> 'format' = 'debezium-json', 'source.monitor-interval' = '2s');
> CREATE TABLE orders (
> order_id BIGINT,
> currency AS CASE order_id % 3 WHEN 0 THEN 'A' WHEN 1 THEN 'B' ELSE 'C' END,
> ts AS LOCALTIMESTAMP,
> WATERMARK FOR ts AS ts - 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 out_print (order_id BIGINT, currency STRING, rate DOUBLE) WITH
> ('connector' = 'print');
> {code}
> {{SET}} statements stay active for the rest of the SQL Client session. The
> sections below tell you when to {{RESET}} a setting. Alternatively, restart
> the SQL Client between sections.
> The operator metrics are visible in the Web UI ({{http://localhost:8081}}):
> open the job, click the {{LateralSnapshotJoin}} vertex and go to the Metrics
> tab (the metric names are prefixed with {{LateralSnapshotJoin[n].}}). The
> same values are available via REST, e.g. {{GET
> /jobs/<job-id>/vertices/<vertex-id>/subtasks/metrics?get=LateralSnapshotJoin[9].currentPhase}}.
> The operator number may differ. Metric values refresh only about every 10
> seconds, so a check such as "immediately" or "after a few seconds" can look
> stale for that long.
> *Build-side changes are applied when the build-side watermark passes their
> row time* (they are buffered until then, see the FLIP). A build row is
> therefore invisible to the join until a later build-side watermark arrives.
> All build files below end with a row for a dummy key {{Z}} with a later
> {{update_time}} for this reason. It pushes the watermark, and no probe row
> matches it. Without it, the last rows of a file stay invisible. In real
> pipelines the next build-side record does this.
> h3. 1. Load phase and flip via idle timeout
> Start with an empty {{/tmp/flip579/build}} and submit:
> {code:sql}
> INSERT INTO out_print
> SELECT o.order_id, o.currency, r.rate
> FROM orders AS o
> JOIN LATERAL SNAPSHOT(
> input => TABLE rates,
> on_time => DESCRIPTOR(update_time),
> load_completed_idle_timeout => INTERVAL '10' SECOND) AS r
> ON o.currency = r.currency;
> {code}
> The probe side runs, but nothing is emitted yet. Then write the initial build
> state:
> {code:bash}
> cat > /tmp/flip579/build/001.json <<'EOF'
> {"before":null,"after":{"currency":"A","rate":1.0,"update_time":"2026-01-01
> 10:00:00"},"op":"c"}
> {"before":null,"after":{"currency":"B","rate":2.0,"update_time":"2026-01-01
> 10:00:00"},"op":"c"}
> {"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-01-01
> 11:00:00"},"op":"c"}
> EOF
> {code}
> Expected:
> * Before the file is written, {{currentPhase}} = 0 and
> {{numProbeSideRecordsBuffered}} grows by about 1 per second. Nothing is
> printed.
> * About 10 seconds after the last build-side watermark advance, the join
> flips: {{currentPhase}} = 1 and {{numProbeSideRecordsBuffered}} drops to 0.
> * All buffered orders are printed at once: A orders with rate 1.0, B orders
> with rate 2.0. C orders are not printed (inner join), and
> {{numUnmatchedProbeRecords}} counts them.
> * New orders are printed immediately afterwards.
> * {{currentBuildSideWatermark}} = {{1767265200000}} ({{2026-01-01 11:00:00}}
> UTC). {{currentProbeSideWatermark}} is close to the current time in epoch
> milliseconds. {{maxJoinFanOut}} = 1.
> h3. 2. Stuck in the load phase
> Cancel the job and empty the build directory ({{rm /tmp/flip579/build/\*}}).
> Submit the same query *without* {{load_completed_idle_timeout}}, and drop
> {{001.json}} again. The default {{load_completed_time}} is the wall-clock
> time at query compilation, so the build-side watermark (January 2026) never
> reaches it.
> Expected: {{currentPhase}} stays 0, {{numProbeSideRecordsBuffered}} keeps
> growing, there is no output, and {{currentBuildSideWatermark}} stays at
> {{1767265200000}}.
> Then fix it with an explicit load completion time. Cancel the job, empty the
> build directory, and submit:
> {code:sql}
> INSERT INTO out_print
> SELECT o.order_id, o.currency, r.rate
> FROM orders AS o
> JOIN LATERAL SNAPSHOT(input => TABLE rates, on_time =>
> DESCRIPTOR(update_time),
> load_completed_time => CAST(TIMESTAMP '2026-01-02 00:00:00' AS
> TIMESTAMP_LTZ(3))) AS r
> ON o.currency = r.currency;
> {code}
> Drop {{001.json}} (its dummy {{Z}} row moves the watermark to 2026-01-01
> 11:00, which is below the load completion time). The join stays in the load
> phase. Then drop a file that moves the watermark past the load completion
> time:
> {code:bash}
> cat > /tmp/flip579/build/002.json <<'EOF'
> {"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-01-02
> 00:00:00"},"op":"c"}
> EOF
> {code}
> Expected: the join flips as soon as the watermark reaches 2026-01-02 00:00:00
> ({{currentBuildSideWatermark}} = {{1767312000000}}), {{currentPhase}} = 1,
> buffered orders are printed.
> h3. 3. Build-side updates, deletes and LEFT JOIN
> Cancel the job, empty the build directory and submit the section 1 query as
> {{LEFT JOIN LATERAL SNAPSHOT(...)}} (still with the idle timeout). Drop
> {{001.json}} from section 1 and wait for the flip. A orders have rate 1.0, B
> orders 2.0, and C orders are printed with a {{NULL}} rate. Then drop:
> {code:bash}
> cat > /tmp/flip579/build/003.json <<'EOF'
> {"before":{"currency":"A","rate":1.0,"update_time":"2026-01-01
> 10:00:00"},"after":{"currency":"A","rate":1.5,"update_time":"2026-02-01
> 10:00:00"},"op":"u"}
> {"before":{"currency":"B","rate":2.0,"update_time":"2026-01-01
> 10:00:00"},"after":null,"op":"d"}
> {"before":null,"after":{"currency":"C","rate":3.0,"update_time":"2026-02-01
> 10:00:00"},"op":"c"}
> {"before":null,"after":{"currency":"Z","rate":0.0,"update_time":"2026-02-02
> 10:00:00"},"op":"c"}
> EOF
> {code}
> Expected after a few seconds:
> * New A orders have rate 1.5. Rate 1.0 is never mixed with 1.5 in an
> inconsistent way: the update becomes visible atomically.
> * New B orders have a {{NULL}} rate (the row was deleted).
> * New C orders have rate 3.0.
> * Already printed rows are never changed. The result is append-only.
> * {{numUnmatchedBuildRetractions}} stays 0, because all retractions hit
> existing rows.
> * Note: {{numUnmatchedProbeRecords}} stays 0 for a LEFT JOIN, although
> unmatched rows are emitted null-padded. It only counts unmatched rows of
> INNER joins (the docs describe it more generally).
> h3. 4. Planning-time validation
> Create the extra tables and views (add to the SQL Client session):
> {code:sql}
> CREATE TABLE rates_nowm (currency STRING, rate DOUBLE, update_time
> TIMESTAMP(3))
> WITH
> ('connector'='filesystem','path'='/tmp/flip579/build','format'='debezium-json','source.monitor-interval'='2s');
> CREATE VIEW rates_rand AS SELECT currency, rate, RAND() AS r, update_time
> FROM rates;
> CREATE VIEW probe_agg AS SELECT currency, COUNT(*) AS c FROM orders GROUP BY
> currency;
> {code}
> Each of the following statements must be rejected with the quoted error. The
> full statement is {{EXPLAIN SELECT o.order_id FROM orders AS o <fragment> ON
> o.currency = r.currency;}}, with the {{<fragment>}} from the first column (in
> the {{probe_agg}} row the fragment replaces {{orders AS o}} as well, and the
> standalone {{SNAPSHOT}} row is a complete statement without {{ON}}). For
> {{RIGHT}}/{{FULL}} use the arguments {{(input => TABLE rates, on_time =>
> DESCRIPTOR(update_time))}}. A failing statement aborts a {{\-f}} script, so
> paste the statements one at a time into the interactive client.
> || Statement || Expected error ||
> | {{JOIN LATERAL SNAPSHOT(input => TABLE rates_nowm, on_time =>
> DESCRIPTOR(update_time)) AS r}} | {{Argument 'on_time' of SNAPSHOT must
> reference a rowtime attribute (a column with a watermark), but column
> 'update_time' is not one.}} |
> | {{JOIN LATERAL SNAPSHOT(input => TABLE rates) AS r}} (no {{on_time}}) |
> {{LATERAL SNAPSHOT requires the 'on_time' argument to identify the build-side
> rowtime attribute.}} |
> | {{on_time => DESCRIPTOR(rate)}} | {{Argument 'on_time' of SNAPSHOT must
> reference a TIMESTAMP or TIMESTAMP_LTZ column (up to precision 3), but column
> 'rate' has type 'DOUBLE'.}} |
> | {{FROM probe_agg AS o JOIN LATERAL SNAPSHOT(input => TABLE rates, on_time
> => DESCRIPTOR(update_time)) AS r}} | {{The probe (left) input of LATERAL
> SNAPSHOT join doesn't support consuming update changes ...}} |
> | {{RIGHT JOIN LATERAL SNAPSHOT(...)}} / {{FULL JOIN LATERAL SNAPSHOT(...)}}
> | {{LATERAL SNAPSHOT join only supports INNER JOIN and LEFT OUTER JOIN, but
> was RIGHT JOIN.}} (and {{FULL JOIN}}) |
> | {{SELECT * FROM TABLE(SNAPSHOT(input => TABLE rates, on_time =>
> DESCRIPTOR(update_time)))}} | {{The SNAPSHOT function can only be used as the
> build side (right-hand side) of a LATERAL join. ...}} |
> Non-deterministic build columns (FLINK-40890). The check is part of the
> non-deterministic update handling, so enable it first:
> {code:sql}
> SET 'table.optimizer.non-deterministic-update.strategy' = 'TRY_RESOLVE';
> EXPLAIN SELECT o.order_id, r.rate, r.r FROM orders AS o
> JOIN LATERAL SNAPSHOT(input => TABLE rates_rand, on_time =>
> DESCRIPTOR(update_time)) AS r ON o.currency = r.currency;
> -- TableException: The column(s): r(generated by non-deterministic function:
> RAND ) can not satisfy the determinism requirement ...
> RESET 'table.optimizer.non-deterministic-update.strategy';
> {code}
> The same statement is accepted with the default strategy ({{IGNORE}}), and
> also with {{TRY_RESOLVE}} if the non-deterministic column is not selected.
> The {{TABLE}} keyword in {{LATERAL}} calls is optional. Verify that {{JOIN
> LATERAL SNAPSHOT(...)}}, {{JOIN LATERAL TABLE(SNAPSHOT(...))}} and {{CROSS
> JOIN LATERAL SNAPSHOT(...) ... WHERE o.currency = r.currency}} all plan, and
> that {{EXPLAIN}} shows a {{LateralSnapshotJoin}} node.
> h3. 5. Join predicates and parallelism
> Run the section 1 query as an {{INSERT INTO}} with {{SET
> 'parallelism.default' = '2';}} and different {{ON}} clauses, and check the
> parallelism of the {{LateralSnapshotJoin}} vertex in the Web UI. At
> parallelism 2, one build-side source subtask never reads data, so aggregated
> values of {{currentBuildSideWatermark}} can show Long.MIN_VALUE. This is
> expected, so look at the metric per subtask. Cancel each job before
> submitting the next one, and run {{RESET 'parallelism.default';}} when you
> are done.
> * {{ON o.currency = r.currency AND o.order_id >= 0}} (equi and non-equi
> mixed): the join runs with parallelism 2.
> * {{ON o.currency <> r.currency}} (no equality predicate): the join runs with
> parallelism 1, while the sources run with 2. After flipping, each order is
> joined with all non-matching currencies, so {{maxJoinFanOut}} > 1 and
> {{avgJoinFanOut}} shows the average (the dummy {{Z}} row also matches).
> h3. 6. State TTL
> Run the section 1 query with an additional {{state_ttl => INTERVAL '20'
> SECOND}}, and drop {{001.json}}. After the flip and the TTL ({{t ~ 40-50 s}}):
> * {{numStateTtlEvictions}} = 1. The key {{Z}} was never accessed by a probe
> row and is evicted.
> * The keys {{A}} and {{B}} are accessed by probe rows all the time, so they
> are kept and orders are still matched with their rates.
> * {{numStateTtlEvictions}} stays 0 during the load phase.
> h3. 7. Savepoint and restore
> *Join phase.* Run the job from section 6 (or section 1) until
> {{currentPhase}} = 1, then:
> {code:bash}
> ./bin/flink stop <job-id>
> # prints: Savepoint completed. Path:
> file:/tmp/flip579/savepoints/savepoint-...
> {code}
> In the SQL Client, run {{SET 'execution.state-recovery.path' = '<savepoint
> path>';}} and submit the same {{INSERT INTO}} again. Then run {{RESET
> 'execution.state-recovery.path';}}, otherwise every later submission in this
> session restores from that savepoint. Expected:
> * The join is in the join phase right away ({{currentPhase}} = 1), without
> any new build-side data.
> * A and B orders are still matched. The build-side state was restored.
> * The metric values are not restored. Counters start at 0, and
> {{currentBuildSideWatermark}} is {{\-9.2E18}} (Long.MIN_VALUE) until the next
> build-side watermark arrives.
> *Load phase.* Cancel the job and empty the build directory. Submit the second
> query of section 2 (with {{load_completed_time}}) without build data. Let it
> buffer about 30 probe rows, then stop it with {{./bin/flink stop}}. Restore
> it with {{execution.state-recovery.path}} (and {{RESET}} it afterwards), and
> verify that {{currentPhase}} = 0 after the restore. Then drop {{001.json}}
> with the {{Z}} row at {{2026-01-03 00:00:00}}. Expected: the join flips, and
> the output contains the orders that were buffered *before* the savepoint
> ({{order_id}} starting at 1, with rates for A and B). The
> {{numProbeSideRecordsBuffered}} gauge only counts rows seen since the
> restore, so check the output instead.
> h3. 8. Batch mode
> {code:bash}
> mkdir -p /tmp/flip579/orders_b /tmp/flip579/rates_b
> printf '1,A\n2,B\n3,C\n4,A\n' > /tmp/flip579/orders_b/o.csv
> printf 'A,1.0\nB,2.0\nA,1.5\n' > /tmp/flip579/rates_b/r.csv
> {code}
> {code:sql}
> SET 'execution.runtime-mode' = 'batch';
> CREATE TABLE orders_b (order_id BIGINT, currency STRING) WITH ('connector' =
> 'filesystem', 'path' = '/tmp/flip579/orders_b', 'format' = 'csv');
> CREATE TABLE rates_b (currency STRING, rate DOUBLE) WITH ('connector' =
> 'filesystem', 'path' = '/tmp/flip579/rates_b', 'format' = 'csv');
> SELECT o.order_id, o.currency, r.rate
> FROM orders_b AS o
> LEFT JOIN LATERAL SNAPSHOT(input => TABLE rates_b, state_ttl => INTERVAL '1'
> HOUR) AS r
> ON o.currency = r.currency ORDER BY o.order_id, r.rate;
> RESET 'execution.runtime-mode';
> {code}
> Expected: the query finishes without watermarks and without {{on_time}}.
> {{state_ttl}} is accepted and ignored. The result is the regular left join
> against the complete build side (the SQL Client renders it as a table and
> prints {{<NULL>}}; the rows are the same):
> {noformat}
> 1 | A | 1.0
> 1 | A | 1.5
> 2 | B | 2.0
> 3 | C | NULL
> 4 | A | 1.0
> 4 | A | 1.5
> {noformat}
> Repeated runs return the same result. {{EXPLAIN}} of the inner join variant
> shows a regular join ({{HashJoin}}/{{AdaptiveJoin}}) and no
> {{LateralSnapshotJoin}}.
> h3. 9. Table API
> Save the following as {{LsjTableApi.java}} in {{build-target}} and run it
> from there with {{java -cp "lib/\*" LsjTableApi.java}} from {{build-target}}
> (Java 17 single-file launch). The columns of the two tables must have
> different names, hence the {{r_}} prefix.
> {code:java}
> import static org.apache.flink.table.api.Expressions.*;
> import org.apache.flink.table.api.*;
> public class LsjTableApi {
> public static void main(String[] args) throws Exception {
> TableEnvironment env =
> TableEnvironment.create(EnvironmentSettings.inStreamingMode());
> env.executeSql(
> "CREATE TABLE rates (r_currency STRING, r_rate DOUBLE,
> update_time TIMESTAMP(3),"
> + " WATERMARK FOR update_time AS update_time) WITH ("
> +
> "'connector'='filesystem','path'='/tmp/flip579/build','format'='debezium-json')");
> env.executeSql(
> "CREATE TABLE orders (order_id BIGINT, currency STRING, ts AS
> LOCALTIMESTAMP,"
> + " WATERMARK FOR ts AS ts - INTERVAL '1' SECOND)
> WITH ('connector'='datagen')");
> Table orders = env.from("orders");
> Table rates = env.from("rates");
> Table inner = orders.joinLateral(
> call("SNAPSHOT", rates.asArgument("input"),
> descriptor("update_time").asArgument("on_time")),
> $("currency").isEqual($("r_currency")));
> System.out.println(inner.explain());
> Table left = orders.leftOuterJoinLateral(
> call("SNAPSHOT", rates.asArgument("input"),
> descriptor("update_time").asArgument("on_time")),
> $("currency").isEqual($("r_currency")));
> System.out.println(left.explain());
> }
> }
> {code}
> Expected: both plans contain a {{LateralSnapshotJoin(joinType=[InnerJoin])}}
> and {{LateralSnapshotJoin(joinType=[LeftOuterJoin])}} node respectively.
> Optionally replace {{explain()}} with {{executeInsert}} into a {{print}}
> table and compare the behavior to sections 1 and 3 (the Table API job runs in
> a local MiniCluster, so there is no Web UI).
> h3. Exploratory
> * Real sources: a probe side from Kafka and a build side from an
> {{upsert-kafka}} topic, with watermark alignment
> ({{scan.watermark.alignment.group}}) on both sources and
> {{table.exec.source.idle-timeout}}.
> * A large build-side backlog and a probe-side start offset ({{/\*+
> OPTIONS('scan.startup.mode'=...) \*/}}) to see the amount of buffered probe
> rows and the output spike at the flip.
> * Failure recovery in the join phase (kill a TaskManager). Results after
> recovery may differ from before, but must not contain half-applied update
> pairs.
> * NULL join keys, composite keys, a view or CTE as build side, and
> {{state_ttl}} without an equality predicate (the whole build side is evicted
> at once when idle).
> * RocksDB state backend ({{state.backend.type: rocksdb}}) for sections 1, 3
> and 7.
> 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)