Skip to content

[WIP][SPARK-58724][SS] Incremental state cleanup for streaming dropDuplicates - #57945

Open
jerrypeng wants to merge 6 commits into
apache:masterfrom
jerrypeng:oss-dedup-incremental-cleanup
Open

[WIP][SPARK-58724][SS] Incremental state cleanup for streaming dropDuplicates#57945
jerrypeng wants to merge 6 commits into
apache:masterfrom
jerrypeng:oss-dedup-incremental-cleanup

Conversation

@jerrypeng

@jerrypeng jerrypeng commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

Streaming deduplication (dropDuplicates) evicts all watermark-expired state at once at the end of each batch. This PR adds incremental state cleanup to StreamingDeduplicateExec:
when enabled, up to a configurable number of eviction-eligible state rows are removed per input record processed, spreading eviction cost across the batch instead of paying it all at the
batch boundary; any still-eligible rows left over are removed at batch end as before.

This is gated by the existing config spark.sql.streaming.statefulOperator.incrementalCleanupFactor (added in SPARK-58635 for streaming aggregation, previously read only by the
streamline aggregation operator). When it is 0 (the default), behavior is unchanged — all eviction happens at batch end.

Details:

  • BaseStreamingDeduplicateExec gains incrementalCleanupFactor / doIncrementalCleanup, and its abstract eviction hook changes from evictDupInfoFromState(store): Unit to
    iteratorForEviction(store): Iterator[Any] — an iterator whose next() performs one real state removal, so the record loop can remove up to incrementalCleanupFactor rows per input and
    the completion block drains the remainder. This mirrors the mechanism already used by StatefulStreamlineAggregateExec.
  • StreamingDeduplicateExec.iteratorForEviction uses the existing EvictionIterator (event time read from the state key). Under incremental cleanup it evicts against the late-events
    watermark rather than the eviction watermark: within a batch a record can arrive below the eviction watermark, so state may only be cleaned up to the timestamp before which no further
    input can arrive. The deduplicated output is identical to the default; only the timing of state reclamation changes (it lags by at most one batch).
  • Two new metrics are exposed: numRowsReadDuringEviction and numRowsIncrementallyRemoved (alongside the existing numRemovedStateRows).
  • StreamingDeduplicateWithinWatermarkExec deliberately does not participate in incremental cleanup. Its dedup key excludes the event time (the expiry lives in the value row), so two
    records sharing a key can have different event times; evicting an expired entry mid-batch could make a later, non-late record with the same key be emitted as new instead of dropped as a
    duplicate — an output that would depend on the cleanup factor and store iteration order. It inherits the factor-0 path and continues to evict once at batch end.

Why are the changes needed?

For a long-running streaming query — particularly under Real-Time Mode, where a batch can run for minutes — evicting all expired deduplication state in one pass at the batch boundary
concentrates work at the commit point and lets state grow unbounded within a batch. Incremental cleanup bounds within-batch state growth and smooths the eviction cost across the batch,
matching the capability streaming aggregation already has.

Does this PR introduce any user-facing change?

No behavior change by default. spark.sql.streaming.statefulOperator.incrementalCleanupFactor defaults to 0, which preserves the existing batch-end eviction exactly. Setting it to a
positive value opts a dropDuplicates query into incremental cleanup; the deduplicated output is unchanged, and two new state-operator metrics (numRowsReadDuringEviction,
numRowsIncrementallyRemoved) appear in the streaming progress. dropDuplicatesWithinWatermark is unaffected.

How was this patch tested?

New unit tests:

  • StreamingDeduplicationSuite: with a non-zero incrementalCleanupFactor, deduplication output (and the late-event safety property) is unchanged, and numRowsIncrementallyRemoved
    progresses as expired keys are removed during record processing.
  • StreamingDeduplicationWithinWatermarkSuite: a non-zero incrementalCleanupFactor leaves dropDuplicatesWithinWatermark output and per-batch state counts identical to the default
    (confirming the operator stays opted out).

All existing StreamingDeduplicationSuite and StreamingDeduplicationWithinWatermarkSuite tests continue to pass (the factor-0 default path is unchanged), and dev/scalastyle passes.

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

Co-authored with Claude Code

Streaming deduplication currently evicts all watermark-expired state at once at
batch end. For long-running batches this concentrates eviction cost at the
commit boundary and lets state grow unbounded within a batch. Aggregation
already supports incremental cleanup (SPARK-58635); this brings the same
mechanism to StreamingDeduplicateExec and StreamingDeduplicateWithinWatermarkExec.

When spark.sql.streaming.statefulOperator.incrementalCleanupFactor is > 0, up to
that many eligible state rows are removed per input record processed, spreading
eviction across the batch; any remainder is removed at batch end. When 0 (the
default), behavior is unchanged -- all eviction happens at batch end.

- BaseStreamingDeduplicateExec: add incrementalCleanupFactor/doIncrementalCleanup;
  replace the evictDupInfoFromState(Unit) hook with iteratorForEviction, an
  iterator whose next() performs one real removal so the caller can stop after
  incrementalCleanupFactor removals per input; add numRowsReadDuringEviction and
  numRowsIncrementallyRemoved metrics.
- StreamingDeduplicateExec: implement iteratorForEviction with EvictionIterator
  (event time read from the state key).
- StreamingDeduplicateWithinWatermarkExec: implement iteratorForEviction reading
  expiresAtMicros from the value row (EvictionIterator cannot be reused there).

Under incremental cleanup, eviction uses the late-events watermark rather than
the eviction watermark: within a batch a record can arrive below the eviction
watermark, so state may only be cleaned up to the timestamp before which no
further input can arrive. The deduplicated output is identical to the default;
only the timing of state removal differs (it lags by one batch).

Co-authored-by: Isaac
…rk from incremental cleanup

Review found that incremental cleanup breaks output-equivalence for
StreamingDeduplicateWithinWatermarkExec. Its dedup key is only the user's
columns; the expiry (expiresAtMicros) lives in the value row and is decoupled
from a record's event time, so two records sharing a dedup key can have
different event times. Evicting an expired entry mid-batch then lets a later
non-late record with the same key miss it and be emitted as new, whereas the
batch-end (factor-0) path keeps the entry visible to the whole batch and drops
the record as a duplicate -- an output that depends on the cleanup factor and
store iteration order.

Exclude this operator from incremental cleanup: drop its incrementalCleanupFactor
override so it inherits the factor-0 default and keeps evicting once at batch end
(unchanged behavior). StreamingDeduplicateExec is unaffected -- its key includes
the event time, so any record sharing an evictable key is itself late and is
dropped by the late-events filter first.

Add a regression test asserting a non-zero incrementalCleanupFactor leaves
dropDuplicatesWithinWatermark output and per-batch state counts identical to the
default, and update the config doc accordingly.

Co-authored-by: Isaac
…on in dedup eviction

StreamingDeduplicateExec.iteratorForEviction built the EvictionIterator with
allowMultipleEventTimeColumns hardcoded to false. The full-eviction path it
replaced resolved the event time column with
allowMultipleEventTimeColumns = !allowMultipleStatefulOperators (via
watermarkExpression -> findEventTimeColumn). Under the non-default
spark.sql.streaming.statefulOperator.allowMultiple = false, a dedup key with two
or more event-time columns would flip from "use the first column" to throwing
MULTIPLE_EVENT_TIME_COLUMNS. Since iteratorForEviction serves both the
incremental and the batch-end (factor-0) eviction paths, this affected the
default path too. Pass !allowMultipleStatefulOperators to preserve the prior
behavior.

Co-authored-by: Isaac
…he null-safe conf accessor

StreamingDeduplicateExec read the config via session.sessionState.conf, which
dereferences `session` (SparkSession.getActiveSession.orNull) directly. On a
session-less thread -- e.g. during canonicalization -- that is null and the
strict val is evaluated at construction, risking an NPE. Use the SparkPlan.conf
accessor instead, which falls back to the active/default conf when session is
null, matching the sibling allowMultipleStatefulOperators.

Co-authored-by: Isaac
…olumn comment

The comment on the EvictionIterator call claimed "behavior is unchanged" and
pointed at watermarkExpression -> findEventTimeColumn (which resolves over
child.output). The eviction path this replaced actually resolved the event time
from keyExpressions (watermarkPredicateForKeysForEviction), which is what the
EvictionIterator does and what the runtime and aggregation paths do. Correct the
comment to describe the keyExpressions resolution and note the one narrow
difference from the old path (which additionally forced the watermark expression
over child.output and so could throw MULTIPLE_EVENT_TIME_COLUMNS under the
non-default allowMultiple=false with extra event-time columns outside the key).
No behavior change.

Co-authored-by: Isaac
…he aggregation path

Rename the local `evictionWatermark` in StreamingDeduplicateExec.iteratorForEviction
to `incrementalAwareEvictionWatermark`, matching the streamline aggregation
operator's incremental-cleanup path (and the runtime). Pure rename; no behavior
change.

Co-authored-by: Isaac
@jerrypeng jerrypeng changed the title [WIP] dedup incremental cleanup [WIP][SPARK-58724][SS] Incremental state cleanup for streaming dropDuplicates Aug 12, 2026

@uros-b uros-b left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Is this PR ready @jerrypeng? cc @HeartSaVioR

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.

2 participants