This is an automated email from the ASF dual-hosted git repository.
HeartSaVioR pushed a commit to branch branch-4.x
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.x by this push:
new 0e2a66bd52ad [SPARK-59788][SS] Initialize the global aggregation once
per empty batch in the streamline plan
0e2a66bd52ad is described below
commit 0e2a66bd52ad6388a8377e48dada2ab062ded5fb
Author: Jonathan Gao <[email protected]>
AuthorDate: Sat Oct 3 22:31:08 2026 +0900
[SPARK-59788][SS] Initialize the global aggregation once per empty batch in
the streamline plan
### What changes were proposed in this pull request?
The streamline aggregation plan initializes the global aggregation buffer
exactly once per executed
empty batch: StatefulStreamlineAggregateExec, which already runs on a
single partition (AllTuples)
for a global aggregation, merges the empty grouping key against state when
that partition has no
input, producing the stored value or the initialized buffer. Buffer
initialization still runs on
every input partition, and nothing is seeded for a batch that has input. A
batch whose source RDD has no partitions is also covered. The change
corrects comments that
misdescribed the projection stages, the state-store write timing, the
output-mode timing, and the
aggregation scope.
### Why are the changes needed?
A global streaming aggregation on the streamline plan produced either no
row (a 0-partition source
batch) or duplicate seeded rows (one per empty partition, and an extra
seeded row inside a batch
that had input in another partition). The ordinary plan initializes once
and emits the initialized
result (count 0, sum NULL) for an executed empty batch, so the streamline
plan diverged.
### Does this PR introduce _any_ user-facing change?
Yes, a fix: global aggregations under the streamline plan
(Trigger.RealTime, and the internal
useStreamlineAggregator test config) now emit the initialized/accumulated
result exactly once per
executed empty batch and never seed a batch that has input, matching the
ordinary micro-batch plan.
### How was this patch tested?
New and updated tests in StreamingAggregationSuite (streamline-enabled):
complete/update
empty-first-batch initialization, state continuity across empty and
non-empty batches, exact Update
emissions for a multi-partition empty batch, no seeded row for a non-empty
batch with an empty
sibling partition, initialization from a 0-partition source batch, and the
coalesce(1) SPARK-21977
shape as a regression guard. The real-time-mode suite pins emitted values
for the empty-input case without relying on
cross-batch sink arrival order, and checks that a later empty batch keeps
the accumulated value.
Local runs: StreamingAggregationSuite,
StreamlineStreamingAggregationRealTimeSuite and
RocksDBStateStoreStreamingAggregationSuite: 243 passed, 0 failed;
scalastyle 0 errors/warnings.
Closes #59048 from nahtonaj/sc-246233-streamline-empty-global.
Authored-by: Jonathan Gao <[email protected]>
Signed-off-by: Jungtaek Lim <[email protected]>
(cherry picked from commit 2f4102c8117a087aba1324d52b268f7966670136)
Signed-off-by: Jungtaek Lim <[email protected]>
---
.../org/apache/spark/sql/internal/SQLConf.scala | 11 +-
.../spark/sql/execution/SparkStrategies.scala | 3 +-
.../spark/sql/execution/aggregate/AggUtils.scala | 3 +-
.../stateful/ProjectAggregationBufferExec.scala | 10 +-
.../stateful/StatefulStreamlineAggregateExec.scala | 56 +++++--
.../streaming/runtime/RealTimeModeAllowlist.scala | 7 +-
.../sql/streaming/StreamingAggregationSuite.scala | 177 +++++++++++++++++++++
...reamlineStreamingAggregationRealTimeSuite.scala | 48 ++++++
8 files changed, 290 insertions(+), 25 deletions(-)
diff --git
a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
index 1c50810684ff..fcc3bbc2cfbc 100644
--- a/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
+++ b/sql/catalyst/src/main/scala/org/apache/spark/sql/internal/SQLConf.scala
@@ -3864,10 +3864,13 @@ object SQLConf {
.internal()
.doc("Test/development only, not intended for production use. When true,
plan a streaming " +
"aggregation with the streamline aggregation operator, which merges
each input row " +
- "against state and emits immediately, instead of the microbatch
operators that only emit " +
- "once the batch ends. Real-Time Mode queries use the streamline
operator regardless of " +
- "this config; this flag exists only so the operator can be exercised
under an ordinary " +
- "microbatch trigger in tests, and changes an aggregation's output
timing when set.")
+ "against state and emits an intermediate result per input in Update
mode, instead of the " +
+ "microbatch operators that only emit once the batch ends. Append and
Complete drain the " +
+ "input before producing their mode-specific output, and only
non-session aggregations " +
+ "are planned this way (session windows use a separate planning path).
Real-Time Mode " +
+ "queries use the streamline operator regardless of this config; this
flag exists only so " +
+ "the operator can be exercised under an ordinary microbatch trigger in
tests, and " +
+ "changes an aggregation's output timing when set.")
.version("4.3.0")
.withBindingPolicy(ConfigBindingPolicy.NOT_APPLICABLE)
.booleanConf
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
index c25414a009c0..4d48bed992fd 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/SparkStrategies.scala
@@ -619,7 +619,8 @@ abstract class SparkStrategies extends
QueryPlanner[SparkPlan] {
// A Real-Time Mode batch runs until its duration elapses rather
than until its input
// is exhausted, so an aggregation that only emits once the batch
ends would hold every
// result back for the whole batch. Plan the streamline operator
instead, which merges
- // each input row against state and emits immediately.
+ // each input row against state; Update mode can emit an
intermediate result per
+ // input.
if (isRealTimeMode(child) ||
conf.getConf(SQLConf.STREAMING_USE_STREAMLINE_AGGREGATOR)) {
AggUtils.planStreamlineStreamingAggregation(
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggUtils.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggUtils.scala
index 1bb5b0e916b3..9b0a656b4ece 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggUtils.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/aggregate/AggUtils.scala
@@ -441,7 +441,8 @@ object AggUtils {
* - For each input, do the following
* - Read the previous value for grouping key in state store
* - Merge the input and previous value (if any)
- * - Store the new value to the state store
+ * - Hold the new value in a bounded dirty-write cache, which is
flushed to the state
+ * store on cache eviction or when the input iterator completes
* - Complete (output the current result of the aggregation)
*
* The concept is to aggregate only between input and the state store,
enabling an output to
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/ProjectAggregationBufferExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/ProjectAggregationBufferExec.scala
index 7046e4236aa0..c8ba2a4b284d 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/ProjectAggregationBufferExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/ProjectAggregationBufferExec.scala
@@ -28,8 +28,10 @@ import org.apache.spark.sql.execution.metric.SQLMetrics
* This class handles the part of aggregation functions in the input rows,
based on the function's
* mode. This class intends to either initialize the aggregation buffer or
complete the aggregate
* buffer and produce the result, so it is expected to be used for two
aggregate modes:
- * 1) partial merge 2) final. This class is pass-through and does not perform
the actual
- * aggregation.
+ * 1) partial, which initializes the buffer for each input row, and 2) final,
which completes the
+ * merged buffer. The partial merge stage is the stateful streamline
aggregate, not this class;
+ * this class only initializes or completes a single row's buffer and never
combines rows with
+ * each other.
*/
case class ProjectAggregationBufferExec(
requiredChildDistributionExpressions: Option[Seq[Expression]] = None,
@@ -82,8 +84,8 @@ case class ProjectAggregationBufferExec(
/**
* This class is an implementation of GenericBufferAggregationIterator which
only handles the
- * aggregation buffer of input, depending on the aggregate mode. This class is
pass-through
- * and does not perform the actual aggregation.
+ * aggregation buffer of input, depending on the aggregate mode. It
initializes or completes the
+ * buffer of one row at a time and does not combine rows with each other.
*/
class ProjectAggregationBufferProcessor(
partIndex: Int,
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/StatefulStreamlineAggregateExec.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/StatefulStreamlineAggregateExec.scala
index 18d7d1a68b5f..a93499295789 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/StatefulStreamlineAggregateExec.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/operators/stateful/StatefulStreamlineAggregateExec.scala
@@ -174,20 +174,34 @@ case class StatefulStreamlineAggregateExec(
// one is produced.
//
// flushDirtyWrites is aggIter's completion action, so it runs when
aggIter is exhausted and
- // BEFORE store.commit() in every mode below: Complete drains aggIter
(line ~191) ahead of the
- // commit iterator; Append drains it (~222) ahead of its own; Update
chains it inside the
- // outer CompletionIterator whose completion commits, and draining the
outer drains aggIter
- // first. This ordering is what lets flushDirtyWrites surface a
state-write failure (see its
- // rethrow) in time to fail the task before the batch commits -- a
partial write can never be
- // committed.
+ // BEFORE store.commit() in every mode below: the Complete branch drains
aggIter ahead of
+ // the commit iterator; the Append branch drains it ahead of its own;
Update chains it
+ // inside the outer CompletionIterator whose completion commits, and
draining the outer
+ // drains aggIter first. This ordering is what lets flushDirtyWrites
surface a state-write
+ // failure (see its rethrow) in time to fail the task before the batch
commits -- a partial
+ // write can never be committed.
var tmpRow: UnsafeRow = null
- val aggIter = CompletionIterator[UnsafeRow, Iterator[UnsafeRow]](
- baseIterator.map { row =>
- allUpdatesTimeMs += timeTakenMs {
- tmpRow = aggProcessor.process(row)
+ val processedIter: Iterator[UnsafeRow] =
+ if (groupingExpressions.isEmpty && !baseIterator.hasNext) {
+ // A global aggregation still produces its result for a batch
without input, as
+ // HashAggregateExec does for the ordinary plan. The required
AllTuples distribution
+ // runs this operator on a single partition, so this fires exactly
once per empty batch.
+ Iterator.single(()).map { _ =>
+ allUpdatesTimeMs += timeTakenMs {
+ tmpRow = aggProcessor.processEmptyGroupingKey()
+ }
+ tmpRow
}
- tmpRow
- },
+ } else {
+ baseIterator.map { row =>
+ allUpdatesTimeMs += timeTakenMs {
+ tmpRow = aggProcessor.process(row)
+ }
+ tmpRow
+ }
+ }
+ val aggIter = CompletionIterator[UnsafeRow, Iterator[UnsafeRow]](
+ processedIter,
aggProcessor.flushDirtyWrites() // Lazily evaluated
)
@@ -481,6 +495,24 @@ class StatefulStreamlineAggregationProcessor(
existingValueRef.putRow(output.copy())
output
}
+
+ /**
+ * Merges no input into the empty grouping key of a global aggregation: the
result is the stored
+ * value if one exists, otherwise the initialized buffer, which is also
written to state.
+ */
+ def processEmptyGroupingKey(): UnsafeRow = {
+ val groupingKey = groupingProjection.apply(InternalRow.empty).copy()
+ val buffer = newAggregationBuffer()
+
+ val existingValueRef = dirtyWrites.get(groupingKey)
+ if (existingValueRef.getRow != null) {
+ processRow(buffer, existingValueRef.getRow)
+ }
+
+ val output = generateOutput(groupingKey, buffer)
+ existingValueRef.putRow(output.copy())
+ output
+ }
}
class UnsafeRowReference {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
index 1b9ba9861f80..e8fd87274751 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/streaming/runtime/RealTimeModeAllowlist.scala
@@ -66,9 +66,10 @@ object RealTimeModeAllowlist extends Logging {
"org.apache.spark.sql.execution.joins.BroadcastHashJoinExec",
// Streaming aggregation. A Real-Time Mode batch does not end when its
input is exhausted, so
// an aggregation is planned as the streamline operator, which merges each
input row against
- // state and emits immediately, wrapped by the two buffer-projection
stages that initialize
- // the aggregation buffer and produce the result columns (see
- // AggUtils.planStreamlineStreamingAggregation).
+ // state and emits an intermediate result per input in Update mode (Append
and Complete drain
+ // the input before producing their mode-specific output). It is wrapped
by the two
+ // buffer-projection stages that initialize the aggregation buffer and
produce the result
+ // columns (see AggUtils.planStreamlineStreamingAggregation).
"org.apache.spark.sql.execution.streaming.ProjectAggregationBufferExec",
"org.apache.spark.sql.execution.streaming.StatefulStreamlineAggregateExec",
// Streaming deduplication and the state-store access operators it plans
into. These run in the
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingAggregationSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingAggregationSuite.scala
index 0055b1f91a7c..aab5cb6887ee 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingAggregationSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamingAggregationSuite.scala
@@ -1095,6 +1095,183 @@ class StreamingAggregationSuite extends
StateStoreMetricsTest with Assertions {
)
}
+ // A global aggregation (no grouping key) must return its initialized row
for an executed batch
+ // whose rows are all dropped upstream, so the aggregation's input is empty.
The micro-batch plan
+ // seeds that row for the empty grouping key in HashAggregateExec; the
streamline plan must match.
+ testWithAllStateVersions("streamline aggregation: global aggregation over
empty input emits " +
+ "the initialized result", streamlineEnabled) {
+ val inputData = MemoryStream[Int]
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Complete)(
+ AddData(inputData, 1, 2),
+ // Every row is filtered out: count 0, sum null.
+ CheckLastBatch((0L, null)),
+ AddData(inputData, -5),
+ CheckLastBatch((1L, -5L))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: global aggregation over
empty input emits " +
+ "the initialized result in update mode", streamlineEnabled) {
+ val inputData = MemoryStream[Int]
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Update)(
+ AddData(inputData, 1, 2),
+ CheckLastBatch((0L, null)),
+ // Update mode emits an intermediate result per input row.
+ AddData(inputData, -3, -4),
+ CheckLastBatch((1L, -3L), (2L, -7L))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: complete mode retains the
accumulated global " +
+ "result when a later batch has no input", streamlineEnabled) {
+ val inputData = MemoryStream[Int]
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Complete)(
+ AddData(inputData, -1, -2),
+ CheckLastBatch((2L, -3L)),
+ AddData(inputData, 3, 4),
+ // The batch has no surviving rows; the accumulated result must be
re-emitted unchanged.
+ CheckLastBatch((2L, -3L))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: update mode retains the
accumulated global " +
+ "result when a later batch has no input", streamlineEnabled) {
+ val inputData = MemoryStream[Int]
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Update)(
+ AddData(inputData, -1, -2),
+ CheckLastBatch((1L, -1L), (2L, -3L)),
+ AddData(inputData, 3, 4),
+ CheckLastBatch((2L, -3L))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: global aggregation with
multiple partitions " +
+ "over empty input emits a single initialized row", streamlineEnabled) {
+ val inputData = MemoryStream[Int](2)
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Complete)(
+ AddData(inputData, 1, 2, 3, 4),
+ // Two partitions with no surviving rows must still produce one
initialized global row.
+ CheckLastBatch((0L, null))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: multi-partition empty
batch emits exactly " +
+ "one initialized row in update mode", streamlineEnabled) {
+ val inputData = MemoryStream[Int](2)
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Update)(
+ AddData(inputData, 1, 2, 3, 4),
+ // Both partitions have no surviving rows, and the batch must seed the
global key exactly
+ // once, not once per empty partition.
+ CheckLastBatch((0L, null))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: an empty sibling partition
does not seed " +
+ "a non-empty batch", streamlineEnabled) {
+ val inputData = MemoryStream[Int](2)
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ testStream(aggregated, Update)(
+ // Only one partition carries the surviving row; the empty sibling must
not add a row.
+ AddData(inputData, -5),
+ CheckLastBatch((1L, -5L))
+ )
+ }
+
+ testWithAllStateVersions("streamline aggregation: coalesce(1) with a 0
partition RDD still " +
+ "initializes the global aggregate", streamlineEnabled) {
+ val inputSource = new BlockRDDBackedSource(spark)
+ MockSourceProvider.withMockSources(inputSource) {
+ val aggregated: Dataset[Long] =
+ spark.readStream.format((new
MockSourceProvider).getClass.getCanonicalName)
+ .load().coalesce(1).groupBy().count().as[Long]
+
+ testStream(aggregated, Complete())(
+ // The very first batch is an empty trigger with a 0-partition RDD:
without an
+ // initialization path for it, no state is written and Complete emits
nothing.
+ AddBlockData(inputSource),
+ CheckLastBatch(0),
+ AddBlockData(inputSource, Seq(1)),
+ CheckLastBatch(1),
+ AddBlockData(inputSource), // another empty trigger with a 0-partition
RDD
+ CheckLastBatch(1),
+ AddBlockData(inputSource, Seq(2, 3)),
+ CheckLastBatch(3),
+ StopStream
+ )
+ }
+ }
+
+ testWithAllStateVersions("streamline aggregation: a 0 partition source batch
still initializes " +
+ "the global aggregate", streamlineEnabled) {
+ val inputSource = new BlockRDDBackedSource(spark)
+ MockSourceProvider.withMockSources(inputSource) {
+ val aggregated: Dataset[Long] =
+ spark.readStream.format((new
MockSourceProvider).getClass.getCanonicalName)
+ .load().groupBy().count().as[Long]
+
+ testStream(aggregated, Complete())(
+ // The first batch has no blocks at all: the source RDD has zero
partitions.
+ AddBlockData(inputSource),
+ CheckLastBatch(0),
+ AddBlockData(inputSource, Seq(1)),
+ CheckLastBatch(1),
+ StopStream
+ )
+ }
+ }
+
+ testWithAllStateVersions("streamline aggregation: grouped aggregation over
empty input emits " +
+ "no rows", streamlineEnabled) {
+ val inputData = MemoryStream[Int]
+
+ val aggregated = inputData.toDF()
+ .filter($"value" < 0)
+ .groupBy($"value")
+ .agg(count("*").as("count"))
+
+ testStream(aggregated, Update)(
+ AddData(inputData, 1, 2),
+ // No group is created for an empty input.
+ CheckLastBatch(),
+ AddData(inputData, -5),
+ CheckLastBatch((-5, 1L))
+ )
+ }
+
// Append mode is the one mode that drives the operator's eviction path: a
windowed grouping key
// is emitted only once the watermark passes it, via EvictionIterator (which
removes in hasNext).
// Mirrors the stateStoreSave Append test above so the streamline operator
is held to the same
diff --git
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamlineStreamingAggregationRealTimeSuite.scala
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamlineStreamingAggregationRealTimeSuite.scala
index 5c5409f7a7d5..d6510229e3b3 100644
---
a/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamlineStreamingAggregationRealTimeSuite.scala
+++
b/sql/core/src/test/scala/org/apache/spark/sql/streaming/StreamlineStreamingAggregationRealTimeSuite.scala
@@ -17,6 +17,9 @@
package org.apache.spark.sql.streaming
+import org.scalatest.time.SpanSugar._
+
+import org.apache.spark.sql.Row
import org.apache.spark.sql.execution.streaming.StatefulStreamlineAggregateExec
import
org.apache.spark.sql.execution.streaming.operators.stateful.StreamingAggregationStateManager
import org.apache.spark.sql.execution.streaming.sources.{ContinuousMemorySink,
LowLatencyMemoryStream}
@@ -131,4 +134,49 @@ class StreamlineStreamingAggregationRealTimeSuite extends
StreamRealTimeModeSuit
}
)
}
+
+ testWithAllStateVersions("global aggregation over empty input emits the
initialized row") {
+ val inputData = LowLatencyMemoryStream[Int]
+ val sink = new ContinuousMemorySink()
+
+ val agg = inputData.toDF()
+ .filter($"value" < 0)
+ .agg(count("*").as("count"), sum("value").as("sum"))
+
+ // Update mode may re-emit the current row for no-data batches in
real-time mode, so the
+ // assertions pin the value of every emitted row instead of counting rows.
+ testStream(agg, OutputMode.Update, sink = sink)(
+ StartStream(),
+ AddData(inputData, 1, 2),
+ Execute { _ =>
+ // Every row is filtered out, so the aggregation input is empty for
this batch: only the
+ // initialized value may be emitted, as the ordinary plan does.
+ eventually(timeout(60.seconds)) {
+ assert(sink.allData.nonEmpty, "expected the initialized row for the
empty batch")
+ }
+ assert(sink.allData.forall(_ == Row(0L, null)),
+ s"unexpected rows for the empty batch: ${sink.allData}")
+ },
+ AddData(inputData, -5),
+ Execute { q =>
+ // The sink does not order rows across batches, so the checks do not
rely on arrival
+ // order. Only the batch with -5 and later batches can emit the
accumulated row, so a
+ // second one proves a later empty batch kept the accumulated value; a
reset would emit
+ // the initialized row instead and the count would never reach two.
+ eventually(timeout(60.seconds)) {
+ assert(sink.allData.count(_ == Row(1L, -5L)) >= 2,
+ s"expected the accumulated row from a later empty batch:
${sink.allData}")
+ }
+ val emitted = sink.allData
+ assert(emitted.forall(r => r == Row(0L, null) || r == Row(1L, -5L)),
+ s"unexpected rows: $emitted")
+ val aggregates = q.lastExecution.executedPlan.collect {
+ case a: StatefulStreamlineAggregateExec => a
+ }
+ assert(aggregates.size == 1,
+ s"expected the streamline aggregate operator,
got:\n${q.lastExecution.executedPlan}")
+ },
+ StopStream
+ )
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]