Skip to content

[SPARK-58635][SS] Support for streaming aggregation in Real-Time Mode (RTM) - #57812

Open
jerrypeng wants to merge 14 commits into
apache:masterfrom
jerrypeng:stack/pipelined-shuffle-pr9-rtm-aggregation
Open

[SPARK-58635][SS] Support for streaming aggregation in Real-Time Mode (RTM)#57812
jerrypeng wants to merge 14 commits into
apache:masterfrom
jerrypeng:stack/pipelined-shuffle-pr9-rtm-aggregation

Conversation

@jerrypeng

@jerrypeng jerrypeng commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

What changes were proposed in this pull request?

This PR adds support for streaming aggregation in Real-Time Mode (RTM), building on the pipelined
shuffle and RTM stateful work from SPARK-58508.

RTM cannot use the ordinary streaming aggregation plan. The standard plan materializes a partial
aggregate before the shuffle and holds results until the batch ends, which is incompatible with RTM's
requirement to emit rows continuously within a long-running batch. This PR introduces a streamline
aggregation plan that reads, merges, and emits per row instead.

The plan is three operators:

  1. ProjectAggregationBufferExec (Partial) - projects input rows into aggregation buffers.
  2. StatefulStreamlineAggregateExec (PartialMerge) - the stateful core. For each row it reads the
    current buffer from the state store, merges the new value, and emits the updated buffer
    immediately. Buffers written during the batch are held in a bounded cache (Guava maximumSize)
    and flushed to the state store at batch end.
  3. ProjectAggregationBufferExec (Final) - projects the merged buffers to the output schema.

Supporting pieces:

  • GenericBufferAggregationIterator - shared buffer-merge iteration used by the operators above.
  • EvictionIterator - incremental state-store cleanup, so eviction cost is spread across the batch
    rather than paid in one pass at batch end.
  • AggUtils.planStreamlineStreamingAggregation - the planner entry point, selected by
    StatefulAggregationStrategy when the plan is an RTM plan.
  • IncrementalExecution wires the operators into the RTM path and admits the
    stateStoreSave <-> StatefulStreamlineAggregate operator transition in the state-metadata check
    (the two share the same state manager and format, so their on-disk state is mutually readable);
    RealTimeModeAllowlist admits the new operators.
  • Two new internal configs: spark.sql.streaming.useStreamlineAggregator (default false) and
    spark.sql.streaming.statefulOperator.incrementalCleanupFactor (default 0, meaning cleanup at
    batch end). RTM uses the streamline operator regardless of the first config; it exists so the
    operator can also be exercised under a microbatch trigger.

The change is inert outside RTM: the streamline plan is only selected for a plan containing a
RealTimeStreamScanExec leaf (or when the config above is set), so the ordinary microbatch
aggregation path is untouched.

Why are the changes needed?

Before this PR, a streaming aggregation was rejected in RTM: it planned into HashAggregateExec,
which is not in RealTimeModeAllowlist, so the query failed at start with
STREAMING_REAL_TIME_MODE.OPERATOR_OR_SINK_NOT_IN_ALLOWLIST. Aggregation is one of the most common
streaming workloads, so this was a significant gap in RTM's coverage. SPARK-58508 enabled stateful
queries in RTM (deduplication); this extends that to aggregation.

Does this PR introduce any user-facing change?

Yes, additive. A streaming aggregation query can now run under Trigger.RealTime(...) where it
previously failed at query start. Behavior outside RTM is unchanged: the two new configs are
internal() and default off, and the streamline plan is only selected for an RTM plan.

How was this patch tested?

New and updated tests:

  • StreamlineStreamingAggregationRealTimeSuite - end-to-end RTM aggregation coverage, including
    incremental cleanup.
  • StreamingAggregationSuite - the shared suite now also exercises the streamline operator (via the
    useStreamlineAggregator config), so the new path is covered by the same assertions as the
    existing micro-batch path. Adds a test that a query keeps its state when switched between the
    micro-batch and streamline operators in either direction, guarding the operator-transition
    allowance above.
  • StreamRealTimeModeAllowlistSuite - updated for the newly admitted operators; the obsolete
    aggregation-rejection test is removed.

Results on this branch: StreamingAggregationSuite 55/55,
StreamlineStreamingAggregationRealTimeSuite + StreamRealTimeModeAllowlistSuite 8/8, and the
streamline cases in RocksDBStateStoreStreamingAggregationSuite 26/26.

Several defects were caught by these tests during development and fixed, each in its own commit:
counting every output row in ProjectAggregationBufferExec; grouping the final aggregate by
attributes rather than expressions; copying the grouping key before caching it; allowing the
micro-batch <-> streamline operator transition on restart; and failing the task (rather than silently
dropping the write and still committing) when a state-store write fails during the batch-end flush.

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

Co-authored with Claude Code

An AggregationIterator that produces a fresh aggregation buffer on demand,
choosing an UnsafeRow buffer when every aggregate buffer attribute is mutable and
a GenericInternalRow otherwise.

This is the shared base for the aggregation processors used by Real-Time Mode
streaming aggregation, added in following commits. It is useful where the buffer
has to be initialized more often than the cardinality of the grouping keys, which
is the case when a stateful operator merges each input row against state rather
than accumulating a hash table per batch.

Co-authored-by: Isaac
A pass-through aggregate operator that only handles the aggregation buffer of its
input according to the aggregate mode: it either initializes the buffer or
completes it and produces the result. It performs no actual aggregation.

Real-Time Mode streaming aggregation uses it twice -- as the partial stage that
initializes buffers and as the final stage that projects the result columns --
around the stateful operator that does the merging. Added in a following commit.

Co-authored-by: Isaac
…on state manager

Removing state rows older than the watermark is currently a side-effecting
`removeKeysOlderThanWatermark`, which reports nothing back. An operator that has to
emit the rows it evicts, or count how many it removed, cannot use it.

Add EvictionIterator: it walks a state store, removes the rows whose event time is
older than a given timestamp, returns exactly those rows, and tracks how many rows
it examined and removed. The event time comes from the state store key via the
watermark metadata on the key attributes, so a key with no event time column
yields an empty iterator.

Real-Time Mode streaming aggregation, added in a following commit, needs both
properties: append mode outputs a grouping key once the watermark passes it, and
incremental cleanup counts real removals rather than rows scanned. Existing
operators are unchanged.

Co-authored-by: Isaac
A stateful streaming aggregation operator that streamlines the read-process-output
loop: for each input row it reads the previous value for the grouping key, merges
the two, emits the merged result immediately, and buffers the state write to be
flushed at the end of the batch. Microbatch streaming aggregation
(StateStoreSaveExec) instead emits only once the batch ends, which is not viable
for Real-Time Mode where a batch runs until its duration elapses.

All three output modes are supported: complete emits the whole result table per
batch, append emits a grouping key once the watermark passes it, and update emits
an intermediate result per input row.

Also adds the operator name constant and two internal configs it reads: one
selecting the operator under a microbatch trigger for testing, and the incremental
cleanup factor (default 0, meaning eviction happens only at the end of a batch).
Nothing plans this operator yet; that follows.

Co-authored-by: Isaac
Plans a streaming aggregation as: initialize the aggregation buffer, shuffle on the
grouping keys, merge each input row against the state store emitting as it goes,
then project the result columns.

Aggregating only between an input row and the state store means an output is
available from processing a single row, so in update mode there is no blocking
operation and output latency is as low as possible. Nothing calls this yet; the
planner change follows.

Co-authored-by: Isaac
…ator in Real-Time Mode

A Real-Time Mode batch runs until its duration elapses rather than until its input
is exhausted, so a streaming aggregation that only emits once the batch ends would
hold every result back for the whole batch. Plan the streamline operator for those
queries instead, which merges each input row against state and emits immediately.

A query is in Real-Time Mode when it reads a source whose relation carries a
real-time mode duration -- the same signal that decides whether to plan a
RealTimeStreamScanExec for it. The new internal
spark.sql.streaming.useStreamlineAggregator config selects the operator under a
microbatch trigger as well, so it can be tested there. Microbatch and batch
aggregation are otherwise unchanged.

Co-authored-by: Isaac
…me Mode

Give the new operators what every stateful operator needs from IncrementalExecution
and let the Real-Time Mode allowlist admit them:

- assign a state operator id, partition count and output mode to
  StatefulStreamlineAggregateExec, and propagate the per-batch late-event and
  eviction watermarks to it;
- set the shuffle partition count on ProjectAggregationBufferExec, which it needs
  to derive its required child distribution as a streaming aggregate;
- admit both operators in RealTimeModeAllowlist.

With this, a streaming aggregation runs end-to-end in Real-Time Mode.

Co-authored-by: Isaac
Two tests over both state format versions:

- a keyed aggregation runs in Real-Time Mode and is planned as the streamline
  aggregate operator. Update mode emits an intermediate result per input row, so a
  key that appears twice is seen twice, once merged -- micro-batch aggregation would
  emit only the final value per key per batch.
- with incremental cleanup enabled, rows that incremental eviction does not reach
  are still removed before the batch completes, checked through the
  numRowsIncrementallyRemoved and numRowsRemoved progress metrics.

The second test pins the shuffle partition count, since the eviction counts it
asserts are per state store.

Co-authored-by: Isaac
"stateful queries not allowed" asserted that a Real-Time Mode streaming aggregation
was rejected by the operator allowlist, because it planned into the micro-batch
aggregation operators and HashAggregateExec is not allowlisted. Aggregation is now
planned as the streamline aggregate operator, which is allowlisted, so there is no
rejection left to assert -- including for a global aggregation with no grouping
keys, which I checked separately.

The generic operator-allowlist test in the same suite still guards the operators
that remain unsupported, and the new StreamlineStreamingAggregationRealTimeSuite
covers the aggregation itself.

Co-authored-by: Isaac
…treamingAggregationSuite

Five tests, each over both state format versions, selecting the streamline operator
through spark.sql.streaming.useStreamlineAggregator so it is covered under a
micro-batch trigger alongside the micro-batch versions of the same queries:

- update mode emits one output per input row. Four rows for the same key produce
  four outputs, where micro-batch aggregation emits only the final value for the
  batch. This is the operator's distinguishing behaviour.
- complete mode outputs the whole result table per batch.
- sum, min, max and avg over a grouping key.
- multiple grouping keys.
- state survives a stop and restart, so counts continue rather than restarting.

They sit here rather than in a new file because the suite already provides
testWithAllStateVersions and the per-test config plumbing, the micro-batch
expectations for the same queries are right alongside for comparison, and the
existing RocksDB subclass re-runs them against that state store for free.

Co-authored-by: Isaac
…amline aggregate

groupingProjection hands back the same UnsafeRow on every call, overwriting its
bytes, and the deferred-write cache stored that row directly as its key. One shared
row therefore became the key of every entry: an entry could only be found again
through the hash captured when it was inserted, and the equality check that should
discriminate between keys degenerated to a reference match against that one row.

Aggregate results happen to come out correct today because the stored hash does the
discriminating, but the cache is not behaving as the value semantics it is written
against would imply, and any change to how the key is compared would silently
produce wrong aggregates. The existing comment at the lookup already called for the
copy; this makes the code do it.

Co-authored-by: Isaac
… not expressions

The final stage's child is the post-shuffle output of the stateful aggregate, whose
grouping columns are already resolved attributes, so it should group by those. It
was passing the original grouping expressions instead, unlike
planStreamingAggregation's final stage and unlike the stateful aggregate in the same
function, both of which group by the attributes.

Adds a test with a computed grouping key, which is the shape where an expression
that has to be re-evaluated against the child's output would diverge from a plain
attribute reference.

Co-authored-by: Isaac
`iter.map { numOutputRows += 1; aggProcessor.process _ }` passes a block whose
value is the eta-expanded function. The block runs once per partition, so the
counter was incremented once per partition rather than once per row, and the
operator reported a numOutputRows of 1 no matter how many rows it produced.

Take the row as a parameter so the increment happens per element.

Co-authored-by: Isaac
…d silent state-write drop

Fixes for issues found reviewing the streamline aggregation operator, each verified
against real upstream code and, where behavioral, by a probe. Changes are kept
close to the Databricks runtime form so the stack merges back cleanly.

1. IncrementalExecution: allow the stateStoreSave <-> StatefulStreamlineAggregate
   operator transition in the state-metadata check. The two operators share
   StreamingAggregationStateManager and the same state format, so their on-disk
   state is mutually readable, but checkOperatorValidWithMetadata compared operator
   names and aborted the restart. Verified: a same-stream restart that flips the
   operator threw STREAMING_STATEFUL_OPERATOR_NOT_MATCH_IN_STATE_METADATA before
   this change; a streamline-on-streamline restart was unaffected. Mirrors the
   runtime's isOperatorMetadataConvertible, character for character. The operator
   name string "StatefulStreamlineAggregate" is kept as-is: it matches the runtime
   and is persisted into checkpoint metadata, so renaming would break checkpoint
   compatibility on merge-back.

2. StatefulStreamlineAggregateExec: fail the task when a state-store write fails
   during flush. The removal listener performs the essential stateManager.put, but
   Guava logs and swallows any exception a removal listener throws (confirmed by a
   direct probe: invalidateAll() returned normally despite the listener throwing),
   so a failed put was dropped and the batch still committed at the next version,
   silently losing that key's update while the sink already emitted the row.
   StateStoreSaveExec puts inline, so it fails the task on such a write; this
   restores that guarantee by capturing the first failure and rethrowing it from
   flushDirtyWrites. This is the one change ahead of the runtime, which has the
   same latent swallow.

3. EvictionIterator: fix the scaladoc, which claimed "every next() corresponds to
   one removal" while removal actually happens in hasNext (as the code and its own
   inline comment describe).

4. SQLConf: reject a negative incrementalCleanupFactor with checkValue; a negative
   silently disabled cleanup. (This config is OSS-only -- the runtime's equivalent
   lives in DatabricksSQLConf under a different key -- so there is no divergence.)

5. Drop the allowlist-deletion comment's unsupported claim of coverage for a
   grouping-key-less aggregation; no such test exists.

Tests: new StreamingAggregationSuite case covering the operator transition in both
directions (revert-checked: fails without fix 1). StreamingAggregationSuite (56),
StreamlineStreamingAggregationRealTimeSuite + StreamRealTimeModeAllowlistSuite (7)
pass.

Co-authored-by: Isaac
@jerrypeng jerrypeng changed the title [WIP] Stack/pipelined shuffle pr9 rtm aggregation [WIP] Support for streaming aggregation in Real-Time Mode (RTM) Aug 7, 2026
@jerrypeng jerrypeng changed the title [WIP] Support for streaming aggregation in Real-Time Mode (RTM) [SPARK-58635][SS] Support for streaming aggregation in Real-Time Mode (RTM) Aug 7, 2026
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.

1 participant