Hi, Jungtaek.
Thank you for sharing the SPIP.
1. The DSv2 interface is missing
Appendix A covers the new hint and SQL configs, but not the DSv2 interfaces and
Python Data Source APIs, which are the main new public contracts. The DSv2
interface is the most long-lived decision because third-party connectors will
implement it, but it is omitted, and Appendix B is skipped. I'd like to see at
least a sketch of:
- how the lookup capability is expressed (a new `TableCapability` or a mix-in
interface)
- how a connector declares which key sets can be looked up (primary key,
indexes)
- the batched lookup signature (a batch of keys in, matching rows out)
- projection and filter pushdown into the lookup
- the lifecycle of lookup readers and connections (per task or per executor)
2. Automatic selection changes the semantics, not just the plan
Today, the choice of a join strategy does not change the query result. A lookup
join, however, changes the semantics: rows with the same key in the same query
(or micro-batch) can see different values, as Q6 acknowledges. With automatic
selection, upgrading a connector that adds this capability would silently
change both the result semantics of existing queries and the load on the
external database. In addition, the selection is based only on the capability,
without any cost consideration. For example, for a 100-row dimension table, a
broadcast hash join is likely cheaper than remote lookups.
Note that Flink's lookup join has the same processing-time semantics. The
difference is that Flink makes it explicit in the syntax (`FOR SYSTEM_TIME AS
OF` with a processing-time attribute). Since the SPIP reuses the existing join
syntax without such a marker, I'd suggest:
- scoping the first version to stream-static joins (Real-Time Mode first)
- requiring an explicit opt-in via a hint or a SQL config (disabled by default)
3. Fall back instead of failing
Q6 says that queries will fail when the static side has operators other than
project and filter. However, the existing join hints are best-effort ("if
doable") and only log a warning when they cannot be applied
(`HintErrorLogger`). Failing in the automatic path would break queries that
work today. So, sources that also support `BATCH_READ` should fall back to the
existing join strategies. On the other hand, a lookup-only source without
`BATCH_READ` (e.g., a store that cannot scan) cannot even be used as the static
side today: `DataSourceV2Utils.loadV2Source` creates a `DataSourceV2Relation`
only for tables with `BATCH_READ`, and `TableCapabilityCheck` rejects a
`DataSourceV2Relation` without it. We need a capability model that covers both
READ+LOOKUP and LOOKUP-only sources.
4. Equality is evaluated by the external store
With a regular join, Spark evaluates `=`. With a lookup join, the external
store evaluates it, and the semantics can differ: collations (Spark's
`UTF8_LCASE`, MySQL's case-insensitive default collation), CHAR padding, type
conversions, NULL and `<=>`, NaN and -0.0. Like the post-scan filters of DSv2
filter pushdown, Spark should re-evaluate the full join condition on the
returned rows to drop extra matches. However, this cannot recover missing
matches (e.g., a `UTF8_LCASE` key against a case-sensitive store). In addition,
a key wrapped in an expression on the lookup side (e.g., a cast, which Spark's
type coercion may add when the key types differ) may not be usable for lookups,
and a key without an index can turn each lookup into a full table scan. This is
why a connector should declare which keys can be looked up and with which
equality semantics, and the planner should match against them.
5. Determinism on retries
A retried task can see different lookup results. In batch and micro-batch
queries, the order-independent shuffle checksum
(`spark.sql.shuffle.orderIndependentChecksum.enabled`, enabled by default since
4.2.0) detects such differences and retries all tasks of the consumer stages,
which protects downstream shuffles but is costly. In Real-Time Mode, this
checksum-based retry does not apply to pipelined shuffles, and
`DAGScheduler.checkPipelinedProducerSupported` rejects a statically
INDETERMINATE producer of a pipelined shuffle, so we need to design how the
lookup join reports its deterministic level there. In addition, for left outer
joins, if a lookup error is not distinguished from "not found", errors will
silently turn into NULL-padded rows.
6. Protecting the external store, and evidence for "low latency"
A remote round trip per record is slower than probing an in-memory broadcast
hash table. So, the benefits come from (a) no waiting for the broadcast at the
start of a batch, (b) fresher data, and (c) joining dimension tables too large
to broadcast without a shuffle. I'd suggest adding a benchmark to the success
criteria to quantify them. The design also needs batching (multi-get), key
deduplication, async I/O with bounded concurrency, timeouts, retries with
backoff, and per-executor connection pooling or rate limiting. Otherwise,
thousands of tasks may hit a single PostgreSQL instance at the same time. For
reference, Flink's LOOKUP hint covers `async`, `output-mode`, `capacity`,
`timeout`, `retry-predicate` (`lookup_miss`), and `shuffle`, while the hint
options proposed so far cover only the cache.
7. Cache design
The SPIP doesn't define where the cache lives (task or executor), how its
memory is accounted (ideally by bytes with the memory manager rather than by
the number of entries), negative caching, or invalidation. In particular, if
`cache_max_rows_per_key` truncates the rows of a key, the join would produce
wrong results. A TTL cache can also serve staler data than today's per-batch
reload, so it should be opt-in on its own. Flink puts the cache settings into
the connector (table) options (`lookup.cache`, `lookup.partial-cache.*`) and
uses hints for execution behavior. The SPIP has both hint options and SQL
configs for the cache, but their precedence is not defined.
Thank you again for driving this.
Dongjoon.
On 2026/10/07 08:30:22 Jungtaek Lim wrote:
> Hi devs,
>
> I'm coming up with a proposal for a new join strategy, named "External
> Lookup Join".
>
> tl;dr:
> All existing join strategies require the static side (DataFrame/Relation)
> to be materialized before the join operator. This doesn't allow us to
> achieve low latency join where the storage of the static side is expected
> to produce low latency response over on-demand request. (e.g. OLTP
> optimized like RDBMS, in-memory storage, NoSQL, etc.) The new join strategy
> will issue the query to the external storage "on demand", when processing
> the data on the left side.
>
> The most benefit will come with real-time mode, but it is not limited to
> real-time mode. This join strategy would be useful as long as the external
> storage of the static side is better suited for on-demand requests (rather
> than bulk load with pruning). Both static-static and stream-static can be
> benefited by this, though static-static join needs to be careful on doing
> this.
>
> More details are included in the SPIP doc.
>
> Issue: https://issues.apache.org/jira/browse/SPARK-60031
> SPIP doc:
> https://docs.google.com/document/d/1fBPM5cku8UJXacG2fw9df1zqALJ-WFm8E9-b9oiFP5M/edit?tab=t.0
>
> Looking forward to hearing from you.
>
> Thanks!
> Jungtaek Lim (HeartSaVioR)
>
---------------------------------------------------------------------
To unsubscribe e-mail: [email protected]