[ 
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-*-taskexecutor-*.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|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-*-taskexecutor-*.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|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)

Reply via email to