Skip to content

[SPARK-58611][SS] Left anti stream-stream join - #57813

Open
ganeshashree wants to merge 9 commits into
apache:masterfrom
ganeshashree:SPARK-58611
Open

[SPARK-58611][SS] Left anti stream-stream join#57813
ganeshashree wants to merge 9 commits into
apache:masterfrom
ganeshashree:SPARK-58611

Conversation

@ganeshashree

@ganeshashree ganeshashree commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Adds LeftAnti support to stream-stream join, which previously failed at analysis time with "LeftAnti joins with a streaming DataFrame/Dataset on the right are not supported".

Why are the changes needed?

Left anti is the complement of left semi (SPARK-32862) and the last missing join type in the stream-stream matrix -- "left rows with no match on the right", e.g. impressions without clicks. LEFT ANTI already works in batch and stream-static, so this is batch/streaming parity.

Does this PR introduce any user-facing change?

Yes. LEFT ANTI stream-stream joins now run in Append mode given a watermark and event-time constraints; queries that previously failed at analysis time now work. No existing behaviour changes. State size is comparable to left outer, not left semi, since every left row is buffered until eviction. The guide's join matrix and a new "Anti Joins with Watermarking" section are updated.

How was this patch tested?

New StreamingLeftAntiJoinSuite with three concrete suites covering state formats v2, v3 and v4 (v4 matters because skipUpdatingMatchedFlag is gated on it). Covers windowed joins across restarts, watermark-gated emission, matched rows never being emitted, pre-join-filter exclusion, the range-condition (state value watermark) path, and Update mode rejection. UnsupportedOperationsSuite gains LeftAnti watermark and output-mode coverage.

Was this patch authored or co-authored using generative AI tooling?

Generated-by: Claude Code (Opus 5)

### What changes were proposed in this pull request?

This adds LeftAnti support to stream-stream join, which previously failed at analysis
time with "LeftAnti joins with a streaming DataFrame/Dataset on the right are not
supported".

Unlike left semi, left anti cannot emit while joining: a semi match is positively
determined, whereas "no match exists" is only decidable once the watermark guarantees no
future right row can match. Left anti is therefore implemented on the eviction path,
reusing the existing left outer plumbing in `StreamingSymmetricHashJoinExec`:

* nothing is emitted when a left row matches;
* at left-side state eviction, rows whose `matched` flag is false are emitted as bare left
  rows (left outer emits them joined with nulls instead);
* a matched left row stays in state carrying `matched = true` so that it is suppressed at
  eviction time, rather than being dropped from state early the way left semi does;
* a left row that fails the pre-join filter can never match, so it is emitted immediately
  without being added to state.

Two details worth calling out for review:

* `AddingProcessedRowToStateCompletionIterator` infers the persisted `matched` flag from
  whether the output iterator is non-empty. Left anti emits nothing on a match, so the
  match status is now passed explicitly via a new optional `matchedOverride` parameter.
  Without it every left row would be stored as unmatched and matched rows would be wrongly
  emitted at eviction. The parameter defaults to the previous behaviour, so the other join
  types are unaffected.
* The joined-row iterator is drained fully rather than short-circuited on the first match,
  because `getJoinedRows` sets the `matched` flag on the other side's rows lazily as they
  are produced. Stopping early would leave some matched left rows flagged as unmatched.

Requirements, mirroring left outer: a watermark on the right side plus time constraints are
mandatory, and Append is the only supported output mode (Update would have to emit rows
before the watermark can rule out a future match, and such a row could be invalidated by a
later batch). No new state format version is needed -- the `matched` flag already persisted
by v2/v3/v4 is exactly the required signal, so existing checkpoints need no migration.
RightAnti remains out of scope.

### Why are the changes needed?

Stream-stream join supported Inner, LeftOuter, RightOuter, FullOuter and LeftSemi. LeftSemi
was added in SPARK-32862 but its complement was never done, leaving the common "find left
rows with no match on the right" pattern -- impressions without clicks, orders without
shipments, sessions without conversion -- without a native streaming implementation. Users
work around it with NOT IN / NOT EXISTS rewrites or hand-written transformWithState logic,
both more expensive and easy to get subtly wrong.

Note stream-static LEFT ANTI already worked when only the left side was streaming; only a
streaming right side was rejected, so the gap was specifically stream-stream.

### Does this PR introduce _any_ user-facing change?

Yes. `LEFT ANTI` stream-stream joins are now supported in Append output mode, given a
watermark on the right side and time constraints. Queries which previously failed at
analysis time now run. No existing behaviour changes.

Left anti buffers every left row until eviction, whereas left semi drops matched left rows
from state eagerly. This is inherent -- a matched row must be retained so that it can be
suppressed at eviction rather than emitted -- so state size for left anti is comparable to
left outer, not to left semi.

The join support matrix in the Structured Streaming guide gains Left Anti rows for
stream-static, static-stream and stream-stream, plus an "Anti Joins with Watermarking"
section.

### How was this patch tested?

New `StreamingLeftAntiJoinSuite` with virtual-column-family and non-VCF variants, covering
windowed anti join across restarts, an unmatched row only being emitted once the watermark
passes it, a row matched in a later batch never being emitted, pre-join-filter exclusion on
both sides, and Update output mode being rejected. `UnsupportedOperationsSuite` gains
LeftAnti coverage for the watermark conditions and for Update/Complete mode rejection.

Verified locally: `UnsupportedOperationsSuite` 226/226 pass; the new left anti suite plus
the existing left semi suite 32/32 pass on both RocksDB and HDFS-backed state store
providers.
…nti join coverage

* Runtime coverage missed state format v4. `skipUpdatingMatchedFlag` in
  `StreamingSymmetricHashJoinExec` is gated on `stateFormatVersion == 4`, so left anti
  takes a distinct path there, but the anti suites only covered v2/v3. Split
  `StreamingLeftAntiJoinSuite` into `StreamingLeftAntiJoinBase` plus a subclass holding
  the V1-V3-only tests -- mirroring the existing `StreamingLeftSemiJoinBase` /
  `StreamingLeftSemiJoinSuite` split -- and add `StreamingLeftAntiJoinV4Suite` alongside
  the other V4 suites so it inherits the runtime tests.

* Range-condition joins use the state value watermark path rather than the state key
  watermark path exercised by the windowed anti tests. Add a
  `setupJoinWithRangeCondition("left_anti")` test covering it, and extend that helper's
  projection to treat `left_anti` like `left_semi` (left columns only).

* The guide said an anti join "must specify watermark on right + time constraints", but
  the analyzer routes LeftAnti through the shared `checkForStreamStreamJoinWatermark`, so
  a watermarked column in the equality join keys on either side is also accepted. Document
  both ways of expressing the event-time constraint.

No change to the implementation -- tests and documentation only.
@uros-b
uros-b requested a review from HeartSaVioR August 6, 2026 08:50
@uros-b

uros-b commented Aug 6, 2026

Copy link
Copy Markdown
Member

Adding @HeartSaVioR for further review in stream-stream join state format area

@HeartSaVioR HeartSaVioR left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was trying to comment each lines but feel like talking about general direction is much better than that.

I see this to be implemented as a variance of LeftOuter, but the spec of LeftAnti is actually a mirror of LeftSemi, meaning so many optimizations we have made for LeftSemi can be applied to LeftAnti. For example, the row in the left side doesn't need to be stored as long as it finds "any" matched row in the right side. This is also applied when the new row in the right side is matched with the left side's row in the state store; we can just remove it from state store rather than updating the matched flag. We don't need to check with matched flag since we wouldn't leave the row which was matched in the state store.

It would give a lot of benefit if we start from LeftSemi, not LeftOuter. Still, LeftAnti should be a hybrid of LeftSemi and LeftOuter (since we need to produce the outer result as the main output), but for the matched rows, the optimization for LeftSemi can be applied to LeftAnti.

… semi

Rework the left anti stream-stream join to be a hybrid of left semi and left
outer, as suggested in review, rather than a variant of left outer.

Left anti is the mirror of left semi: a left row that finds any match can never
be anti output, so it can be dropped from state on match instead of being kept
with a `matched` flag until eviction. This reuses the left semi optimizations:
skip storing a left row that matches on arrival, and remove an already-stored
left row via `getJoinedRowsAndRemoveMatched` when a later right row matches it.
The right side is processed first, as for left semi. Only the eviction-time
emission of the surviving (never-matched) left rows stays left-outer-shaped.

As a result the left-side `matched` flag is no longer consulted at eviction
(every survivor is unmatched by construction), so its v4 `skipUpdatingMatchedFlag`
special-casing is dropped. Row outputs are unchanged; state size for matched
left rows now matches left semi rather than left outer.

Update the affected `assertNumStateRows` expectations accordingly.
@ganeshashree

Copy link
Copy Markdown
Contributor Author

I was trying to comment each lines but feel like talking about general direction is much better than that.

I see this to be implemented as a variance of LeftOuter, but the spec of LeftAnti is actually a mirror of LeftSemi, meaning so many optimizations we have made for LeftSemi can be applied to LeftAnti. For example, the row in the left side doesn't need to be stored as long as it finds "any" matched row in the right side. This is also applied when the new row in the right side is matched with the left side's row in the state store; we can just remove it from state store rather than updating the matched flag. We don't need to check with matched flag since we wouldn't leave the row which was matched in the state store.

It would give a lot of benefit if we start from LeftSemi, not LeftOuter. Still, LeftAnti should be a hybrid of LeftSemi and LeftOuter (since we need to produce the outer result as the main output), but for the matched rows, the optimization for LeftSemi can be applied to LeftAnti.

Thanks, agreed. LeftSemi is the right framing. Reworked in 772c64e as a hybrid of left semi and left outer:

  • left row that matches on arrival isn't stored;
  • already-stored left row matched by a later right row is removed from state (getJoinedRowsAndRemoveMatched), not flag-flipped;
  • right side processed first, as in LeftSemi.

Only the eviction-time emission of surviving never-matched left rows stays left-outer-shaped. Since every survivor is unmatched by construction, the matched flag is no longer read at eviction, so I dropped the v4 skipUpdatingMatchedFlag special-casing too. Outputs unchanged; state size for matched left rows now matches LeftSemi. Updated assertNumStateRows accordingly. PTAL.

…mples

Correct the watermark/state-cleanup explanation in the anti join section: for
left anti it is the right side watermark that lets the engine decide a left row
can no longer match and emit it, so the right watermark drives eviction and
output of left side state, while the optional left watermark is what allows the
right side state to be cleaned up. The previous text had this reversed.

Also add leftAnti / left_anti to the supported join type comments in the
stream-stream join example snippets across all language tabs.
The test asserted empty output throughout (the left row gets matched),
so its "emits an unmatched left row" name was misleading. Rename it to
describe what it verifies and note the positive emission case is covered
by "windowed left anti join".
@HeartSaVioR

Copy link
Copy Markdown
Contributor

Codex found the issue which is actually an existing one but severe with outer-like joins...
If we have only right side watermark (as we documented left side watermark is optional), it actually does not build the predicate to evict the state on the left side. For outer-like joins e.g. left outer and left anti, this won't produce unmatched outer results.
Can you check with the above as constructing tests and confirming the behavior, and if Codex is right about it, block analyzer to allow only right watermark to be present, and also update the doc? We should make a fix for left semi and left outer as well, but for them we may need a SQL config to fall back to old behavior, and update the migration guide doc.

…eft anti stream-stream join

A left anti stream-stream join emits its output (unmatched left rows) from
left-state eviction, so it needs both the left state evicted and the right side
late-filtered on the matching dimension. The generic watermark check guaranteed
neither, so the analyzer accepted configs that silently produced wrong output,
e.g. a right-watermark-only range join (left state never evicted).

Tighten the LeftAnti analyzer check: the range path requires a left-side
watermark; the equi-join path requires the watermark on the right join key at
the eviction ordinal (new StreamingJoinHelper.isWatermarkOnRightEvictionJoinKey).
Left outer/semi are unchanged, pending a separate config-gated fix. Also fix the
docs and add analyzer/runtime coverage.
@ganeshashree

Copy link
Copy Markdown
Contributor Author

Codex found the issue which is actually an existing one but severe with outer-like joins... If we have only right side watermark (as we documented left side watermark is optional), it actually does not build the predicate to evict the state on the left side. For outer-like joins e.g. left outer and left anti, this won't produce unmatched outer results. Can you check with the above as constructing tests and confirming the behavior, and if Codex is right about it, block analyzer to allow only right watermark to be present, and also update the doc? We should make a fix for left semi and left outer as well, but for them we may need a SQL config to fall back to old behavior, and update the migration guide doc.

Fix in the latest FOLLOWUP, scoped to left anti (analyzer + docs):

  • Range-condition path: now requires a watermark on the left side too (right-only is rejected), which lets the left state be evicted.
  • Equi-join path: requires the watermark on the right join key at the ordinal used for eviction (isWatermarkOnRightEvictionJoinKey), so the right side is actually late-filtered on the matching dimension. This also rejects a left-only key watermark, a watermark on an unrelated right column, and a mismatched-ordinal composite key, all of which would otherwise let a late right row invalidate an already-emitted anti-row.
  • Docs/matrix updated, plus the immediate-emission caveat for left rows failing a deterministic left-only pre-join filter.

Left outer/semi are untouched here, as you suggested, they need a SQL-config fallback + migration-guide note, so I'd do that as a separate change. Two related gaps are also pre-existing and shared across all stream-stream joins, so I left them for that follow-up: (1) same-batch matches when the eviction watermark runs ahead of the late-event watermark (SPARK-49829-style split), and (2) range-path column-level precision (which attributes the range constraints vs which is watermarked).

@HeartSaVioR

Copy link
Copy Markdown
Contributor

Sorry to push you on more work than anticipated, but I think the best direction is to fix the case of left semi & left outer with SQL config as a kill switch, and have left anti on top of the fix. We have common code which has the issue and it's weird we workaround to left anti to not use common code and try to fix it only for left anti. (Actually Codex still found a couple issues and trying to fix the issue in left anti would be thrown out as we will fix the shared code anyway.)

Could you please consider expanding your work to fix left semi & left outer as well? I can volunteer if you would like to keep the scope as left anti. Thanks!

…ent check across left semi/outer/anti

Generalize the LeftAnti-only watermark-placement check into a shared check for
left semi/outer/anti. The equi-join path now requires both eviction join keys
to be watermarked, and the range path requires the state watermark to derive
from watermarked attributes on both sides. Left semi/outer are gated by the new
config spark.sql.streaming.join.stricterWatermarkRequirements.enabled (default
true, set false to restore the old behavior); left anti always enforces it.

Also fixes the same-batch (SPARK-49829 split) case for left semi/anti: a
non-late right row that is evicting in the batch is now stored so later
same-batch left rows can match it, and the matched-row removal probe prunes the
V4 state scan by timestamp.

Updates the join docs, support matrix, and migration guide.
…new streaming join config

spark.sql.streaming.join.stricterWatermarkRequirements.enabled only gates a
streaming analyzer validation and never changes a resolved view/UDF/procedure
plan, so it uses NOT_APPLICABLE. Required for SparkConfigBindingPolicySuite.
@ganeshashree

Copy link
Copy Markdown
Contributor Author

Sorry to push you on more work than anticipated, but I think the best direction is to fix the case of left semi & left outer with SQL config as a kill switch, and have left anti on top of the fix. We have common code which has the issue and it's weird we workaround to left anti to not use common code and try to fix it only for left anti. (Actually Codex still found a couple issues and trying to fix the issue in left anti would be thrown out as we will fix the shared code anyway.)

Could you please consider expanding your work to fix left semi & left outer as well? I can volunteer if you would like to keep the scope as left anti. Thanks!

Thanks, expanded in the follow-up commit, no more left-anti workaround. The check now lives in shared code (checkStreamStreamJoinWatermarkPlacement) across left semi/outer/anti:

  • Left semi/outer are gated by a kill switch, spark.sql.streaming.join.stricterWatermarkRequirements.enabled (default true); left anti sits on top and always enforces it.
  • Equi path now requires both eviction keys watermarked; range path requires the bound between watermarked attributes on both sides.

PTAL.

…t-side probe

The left-side probe of a left anti join only needs to know whether the right
state holds any matching row. Add SymmetricHashJoinStateManager.existsJoinedRow,
which short-circuits on the first match and does not update right-side matched
flags that left anti never consults, and use it instead of draining
getJoinedRows.

Also clarify in the docs and analyzer tests that stream-static joins are not
stateful and follow the general output-mode rules, so they are allowed in
Update mode even when the corresponding stream-stream join is Append-only.
@HeartSaVioR

Copy link
Copy Markdown
Contributor

I think the best direction is to fix the case of left semi & left outer with SQL config as a kill switch, and have left anti on top of the fix.

My bad if my proposal wasn't clear. I was thinking of having a new JIRA ticket to change the behavior for Left Semi & Left Outer (it warrants a new ticket due to breaking/behavioral change) and merging the fix (fixing common code in that PR), and getting back to this to scope to Left Anti only. I assume the code change itself won't be complex for the new PR as I expect the code change to be made here already.

Could you please help doing it? Again I can volunteer to do that by myself if you mind. Please let me know.

@ganeshashree

Copy link
Copy Markdown
Contributor Author

I think the best direction is to fix the case of left semi & left outer with SQL config as a kill switch, and have left anti on top of the fix.

My bad if my proposal wasn't clear. I was thinking of having a new JIRA ticket to change the behavior for Left Semi & Left Outer (it warrants a new ticket due to breaking/behavioral change) and merging the fix (fixing common code in that PR), and getting back to this to scope to Left Anti only. I assume the code change itself won't be complex for the new PR as I expect the code change to be made here already.

Could you please help doing it? Again I can volunteer to do that by myself if you mind. Please let me know.

Thanks for clarifying. I created SPARK-58904 to change the behavior for Left Semi and Left Outer. I'll update this PR to include only the left anti on top of it..

@ganeshashree

Copy link
Copy Markdown
Contributor Author

#58238 (SPARK-58904) is the split-out shared fix from this PR's review discussion. It adds a common analyzer check, checkStreamStreamJoinWatermarkPlacement, that enforces correct watermark placement for stream-stream left semi and left outer joins: the left-side state must be evictable (the equi eviction key must be watermarked, or the range bound must sit between watermarked attributes on both sides), and for left outer both eviction-ordinal join keys must be watermarked so a late row cannot invalidate an already-emitted unmatched row. It is gated by a kill switch, spark.sql.streaming.join.stricterWatermarkRequirements.enabled (default true).

Once #58238 lands, I will rebase this PR on top of it and scope it down to left anti only, adding left anti as one more case in that shared check (always enforced, since it is newly supported) rather than the current left-anti-specific handling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants