This is an automated email from the ASF dual-hosted git repository.

He-Pin pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/pekko.git


The following commit(s) were added to refs/heads/main by this push:
     new 612851ec4e fix: add supervision strategy support for batch costFn 
(#3184)
612851ec4e is described below

commit 612851ec4eaaa988f2465523640d2f563dcb883c
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Sat Jun 27 09:51:05 2026 +0800

    fix: add supervision strategy support for batch costFn (#3184)
    
    * fix: add supervision strategy support for batch costFn
    
    Motivation:
    The batch operator's costFn was called outside the supervised boundary in 
onPush,
    so costFn exceptions failed the stream unconditionally. While adding this 
support,
    we also found coupled exceptional-path hazards around pending-element 
handling
    (flush/EOS), where state could leak across batches or a closed inlet could 
be
    pulled after stage failure.
    
    Modification:
    Wrap costFn(elem) in Batch.onPush() with supervision handling: Stop fails,
    Resume drops the element, Restart resets batch state and continues.
    
    Harden pending-element exceptional paths in Batch:
    - clear agg immediately after pushing a batch before processing pending
    - evaluate costFn(pending) before seed(pending) in flush()
    - avoid pull(in) when the inlet is already closed
    - at EOS, complete the stage when pending is dropped by Resume/Restart after
      a seed failure
    
    Expand directional regression coverage in FlowBatchSpec:
    - explicit Stop + default Stop for costFn throws
    - Resume vs Restart semantic distinction
    - pending/flush second costFn-evaluation failures under Resume/Restart/Stop
    - EOS pending-seed failure under Restart
    
    Result:
    batch/batchWeighted now consistently applies supervision for costFn and 
preserves
    correct stage state/pull behavior across exceptional pending paths.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.scaladsl.FlowBatchSpec" -- 14/14 passed
    - sbt "stream/mimaReportBinaryIssues" -- clean
    
    References:
    Refs #3110
    
    * fix: harden Batch supervision Stop paths, document semantics, and add 
directional tests
    
    Motivation:
    Review of the batch costFn supervision support identified three latent
    correctness risks around the Stop supervision path, missing user-facing
    documentation, and gaps in directional test coverage for seed/aggregate
    failures in onPush.
    
    Modification:
    - Add `return` after `failStage(ex)` in three Batch catch blocks
      (flush pending-costFn Stop, onPush seed Stop, onPush aggregate Stop)
      for defensive parity with the costFn Stop handler. Without the early
      return, a user-configured CancellationStrategy.AfterDelay could leave
      the inlet open after failStage, causing flush()/pull(in) to run on a
      stage mid-teardown.
    - Document supervision semantics for costFn/seed/aggregate in batch.md
      and batchWeighted.md.
    - Add two directional tests:
      * resume when seed throws in onPush (exercises agg==null path under 
Resume)
      * resume when aggregate throws in onPush with downstream backpressure
        (exercises the aggregate branch under Resume and verifies the
        accumulated batch is preserved when the offending element is dropped)
    
    Result:
    Batch's supervision handling is defensively consistent across all Stop
    paths, its docs describe the three-strategy behavior, and the seed and
    aggregate failure paths in onPush have directional test coverage.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.scaladsl.FlowBatchSpec 
org.apache.pekko.stream.scaladsl.FlowBatchWeightedSpec" (17/17 passed)
    
    References:
    Refs #3110
---
 .../stream/operators/Source-or-Flow/batch.md       |   5 +
 .../operators/Source-or-Flow/batchWeighted.md      |   5 +
 .../pekko/stream/scaladsl/FlowBatchSpec.scala      | 275 +++++++++++++++++++++
 .../org/apache/pekko/stream/impl/fusing/Ops.scala  |  45 +++-
 4 files changed, 321 insertions(+), 9 deletions(-)

diff --git a/docs/src/main/paradox/stream/operators/Source-or-Flow/batch.md 
b/docs/src/main/paradox/stream/operators/Source-or-Flow/batch.md
index 79c862a8ec..08646f7c40 100644
--- a/docs/src/main/paradox/stream/operators/Source-or-Flow/batch.md
+++ b/docs/src/main/paradox/stream/operators/Source-or-Flow/batch.md
@@ -23,6 +23,11 @@ to the summary type.
 Will eagerly pull elements, this behavior may result in a single pending (i.e. 
buffered) element which cannot be
 aggregated to the batched value.
 
+This operator adheres to the `ActorAttributes.SupervisionStrategy` attribute 
for exceptions thrown by the `costFn`,
+`seed`, or `aggregate` functions. On `Supervision.Stop` (the default) the 
stream fails. On `Supervision.Resume` the
+offending element is dropped and the accumulated batch (if any) is preserved. 
On `Supervision.Restart` the offending
+element is dropped, the accumulated batch is discarded, and the operator 
starts fresh.
+
 ## Reactive Streams semantics
 
 @@@div { .callout }
diff --git 
a/docs/src/main/paradox/stream/operators/Source-or-Flow/batchWeighted.md 
b/docs/src/main/paradox/stream/operators/Source-or-Flow/batchWeighted.md
index 6db2f37f9f..4ff24c1341 100644
--- a/docs/src/main/paradox/stream/operators/Source-or-Flow/batchWeighted.md
+++ b/docs/src/main/paradox/stream/operators/Source-or-Flow/batchWeighted.md
@@ -21,6 +21,11 @@ backpressure.
 Will eagerly pull elements, this behavior may result in a single pending (i.e. 
buffered) element which cannot be
 aggregated to the batched value.
 
+This operator adheres to the `ActorAttributes.SupervisionStrategy` attribute 
for exceptions thrown by the `costFn`,
+`seed`, or `aggregate` functions. On `Supervision.Stop` (the default) the 
stream fails. On `Supervision.Resume` the
+offending element is dropped and the accumulated batch (if any) is preserved. 
On `Supervision.Restart` the offending
+element is dropped, the accumulated batch is discarded, and the operator 
starts fresh.
+
 ## Reactive Streams semantics
 
 @@@div { .callout }
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowBatchSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowBatchSpec.scala
index feb3829861..7360c15c3c 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowBatchSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowBatchSpec.scala
@@ -19,7 +19,9 @@ import scala.concurrent.Await
 import scala.concurrent.duration._
 
 import org.apache.pekko
+import pekko.stream.ActorAttributes
 import pekko.stream.OverflowStrategy
+import pekko.stream.Supervision
 import pekko.stream.testkit._
 
 class FlowBatchSpec extends StreamSpec("""
@@ -120,5 +122,278 @@ class FlowBatchSpec extends StreamSpec("""
       Await.result(future, 3.seconds) should be((1 to 50).sum)
     }
 
+    "fail stream when costFn throws and supervision is Stop" in {
+      val ex = new RuntimeException("boom")
+      val result = Source(1 to 5)
+        .batchWeighted(
+          max = 10,
+          costFn = (i: Int) => if (i == 3) throw ex else i.toLong,
+          seed = (i: Int) => i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.stoppingDecider))
+        .runWith(Sink.ignore)
+      result.failed.futureValue shouldBe ex
+    }
+
+    "fail stream when costFn throws and supervision defaults to Stop" in {
+      // No supervision attribute is set, so the mandatory SupervisionStrategy 
attribute
+      // resolves to the default stopping decider. This guards the default 
code path.
+      val ex = new RuntimeException("boom")
+      val result = Source(1 to 5)
+        .batchWeighted(
+          max = 10,
+          costFn = (i: Int) => if (i == 3) throw ex else i.toLong,
+          seed = (i: Int) => i)(aggregate = _ + _)
+        .runWith(Sink.ignore)
+      result.failed.futureValue shouldBe ex
+    }
+
+    "resume when costFn throws and supervision is Resume" in {
+      val future = Source(1 to 5)
+        .batchWeighted(
+          max = Long.MaxValue,
+          costFn = (i: Int) => if (i == 3) throw new RuntimeException("boom") 
else i.toLong,
+          seed = (i: Int) => i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .runFold(0)(_ + _)
+      Await.result(future, 3.seconds) should be(12) // 1 + 2 + 4 + 5, 3 is 
skipped
+    }
+
+    // The next two tests are a directional pair: with downstream silent the 
whole stream
+    // aggregates into a single batch, so the emitted value reveals whether 
the partial
+    // batch accumulated before the failing element was kept (Resume) or 
discarded (Restart).
+    "keep the accumulated batch when costFn throws and supervision is Resume" 
in {
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = Long.MaxValue,
+          costFn = (i: Int) => if (i == 3) throw new RuntimeException("boom") 
else 1L,
+          seed = (i: Int) => i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      for (i <- 1 to 5) publisher.sendNext(i)
+      publisher.sendComplete()
+      subscriber.expectNoMessage(300.millis)
+      sub.request(1)
+      // 1 + 2 accumulated, element 3 skipped but the partial batch is 
preserved, then 4 + 5 added
+      subscriber.expectNext(1 + 2 + 4 + 5)
+      subscriber.expectComplete()
+    }
+
+    "reset the accumulated batch when costFn throws and supervision is 
Restart" in {
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = Long.MaxValue,
+          costFn = (i: Int) => if (i == 3) throw new RuntimeException("boom") 
else 1L,
+          seed = (i: Int) => i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.restartingDecider))
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      for (i <- 1 to 5) publisher.sendNext(i)
+      publisher.sendComplete()
+      subscriber.expectNoMessage(300.millis)
+      sub.request(1)
+      // 1 + 2 accumulated, then discarded on Restart, element 3 skipped, only 
4 + 5 remain
+      subscriber.expectNext(4 + 5)
+      subscriber.expectComplete()
+    }
+
+    "restart and continue when pending element costFn throws during flush" in {
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+      var callsForThree = 0
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = 3,
+          costFn = (i: Int) => {
+            if (i == 3) {
+              callsForThree += 1
+              if (callsForThree == 2) throw new RuntimeException("boom")
+            }
+            i.toLong
+          },
+          seed = (i: Int) => i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.restartingDecider))
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      // 3 is evaluated once in onPush (becomes pending because left < cost) 
and again in flush.
+      publisher.sendNext(1)
+      publisher.sendNext(2)
+      publisher.sendNext(3)
+      subscriber.expectNoMessage(300.millis)
+
+      sub.request(1)
+      subscriber.expectNext(3)
+
+      // After restart in flush(), the stage must still pull and continue 
processing new elements.
+      publisher.sendNext(4)
+      publisher.sendComplete()
+      sub.request(1)
+      subscriber.expectNext(4)
+      subscriber.expectComplete()
+
+      callsForThree shouldBe 2
+    }
+
+    "resume and continue when pending element costFn throws during flush" in {
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+      var callsForThree = 0
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = 2,
+          costFn = (i: Int) => {
+            if (i == 3) {
+              callsForThree += 1
+              if (callsForThree == 2) throw new RuntimeException("boom")
+            }
+            1L
+          },
+          seed = (i: Int) => i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      // 3 is evaluated once in onPush (becomes pending) and again in flush.
+      publisher.sendNext(1)
+      publisher.sendNext(2)
+      publisher.sendNext(3)
+      subscriber.expectNoMessage(300.millis)
+
+      sub.request(1)
+      subscriber.expectNext(3)
+
+      // On Resume, pending should be dropped without leaking the previously 
emitted batch into new state.
+      publisher.sendNext(4)
+      publisher.sendComplete()
+      sub.request(1)
+      subscriber.expectNext(4)
+      subscriber.expectComplete()
+
+      callsForThree shouldBe 2
+    }
+
+    "stop with the original exception when pending element costFn throws 
during flush" in {
+      val ex = new RuntimeException("boom")
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+      var callsForThree = 0
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = 2,
+          costFn = (i: Int) => {
+            if (i == 3) {
+              callsForThree += 1
+              if (callsForThree == 2) throw ex
+            }
+            1L
+          },
+          seed = (i: Int) => i)(aggregate = _ + _)
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      publisher.sendNext(1)
+      publisher.sendNext(2)
+      publisher.sendNext(3)
+      subscriber.expectNoMessage(300.millis)
+
+      sub.request(1)
+      subscriber.expectNext(3)
+      subscriber.expectError(ex)
+
+      callsForThree shouldBe 2
+    }
+
+    "complete when pending seed throws at end of stream and supervision is 
Restart" in {
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = 1,
+          costFn = (_: Int) => 1L,
+          seed = (i: Int) => if (i == 2) throw new RuntimeException("boom") 
else i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.restartingDecider))
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      publisher.sendNext(1)
+      publisher.sendNext(2)
+      publisher.sendComplete()
+      sub.request(1)
+      subscriber.expectNext(1)
+      subscriber.expectComplete()
+    }
+
+    "resume when seed throws in onPush and supervision is Resume" in {
+      // seed is only called in onPush when agg == null (i.e. no active batch).
+      // Under Resume the failing element must be dropped and the next element
+      // should start a fresh batch.
+      val future = Source(1 to 5)
+        .batchWeighted(
+          max = Long.MaxValue,
+          costFn = (i: Int) => i.toLong,
+          seed = (i: Int) => if (i == 1) throw new RuntimeException("boom") 
else i)(aggregate = _ + _)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .runFold(0)(_ + _)
+      Await.result(future, 3.seconds) should be(2 + 3 + 4 + 5)
+    }
+
+    "resume when aggregate throws in onPush and supervision is Resume" in {
+      // aggregate is only called in onPush when downstream backpressures and 
a batch
+      // is accumulating. Use a manual subscriber to force batching. Under 
Resume
+      // the failing element must be dropped while the accumulated batch is 
preserved.
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.manualProbe[Int]()
+
+      Source
+        .fromPublisher(publisher)
+        .batchWeighted(
+          max = 10,
+          costFn = (_: Int) => 1L,
+          seed = (i: Int) => i)(aggregate =
+          (acc, i) => if (i == 3) throw new RuntimeException("boom") else acc 
+ i)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .to(Sink.fromSubscriber(subscriber))
+        .run()
+      val sub = subscriber.expectSubscription()
+
+      // Send elements without requesting; they accumulate in a single batch.
+      publisher.sendNext(1) // seed -> agg=1
+      publisher.sendNext(2) // aggregate(1,2) -> agg=3
+      publisher.sendNext(3) // aggregate(3,3) throws -> Resume, agg stays 3, 
element 3 dropped
+      publisher.sendNext(4) // aggregate(3,4) -> agg=7
+      publisher.sendNext(5) // aggregate(7,5) -> agg=12
+      publisher.sendComplete()
+
+      subscriber.expectNoMessage(300.millis)
+      sub.request(1)
+      subscriber.expectNext(1 + 2 + 4 + 5)
+      subscriber.expectComplete()
+    }
+
   }
 }
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/Ops.scala 
b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/Ops.scala
index 010260e03d..882ec381a8 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/Ops.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/impl/fusing/Ops.scala
@@ -1081,16 +1081,20 @@ private[stream] object Collect {
         if (agg != null) {
           push(out, agg)
           left = max
+          agg = null.asInstanceOf[Out]
         }
         if (pending != null) {
           try {
+            val cost = costFn(pending)
             agg = seed(pending)
-            left -= costFn(pending)
+            left -= cost
             pending = null.asInstanceOf[In]
           } catch {
             case NonFatal(ex) =>
               decider(ex) match {
-                case Supervision.Stop    => failStage(ex)
+                case Supervision.Stop =>
+                  failStage(ex)
+                  return
                 case Supervision.Restart => restartState()
                 case Supervision.Resume  =>
                   pending = null.asInstanceOf[In]
@@ -1105,7 +1109,23 @@ private[stream] object Collect {
 
       def onPush(): Unit = {
         val elem = grab(in)
-        val cost = costFn(elem)
+        val cost =
+          try costFn(elem)
+          catch {
+            case NonFatal(ex) =>
+              decider(ex) match {
+                case Supervision.Stop =>
+                  failStage(ex)
+                  return
+                case Supervision.Resume =>
+                  pull(in)
+                  return
+                case Supervision.Restart =>
+                  restartState()
+                  pull(in)
+                  return
+              }
+          }
         contextPropagation.suspendContext()
 
         if (agg == null) {
@@ -1115,7 +1135,9 @@ private[stream] object Collect {
           } catch {
             case NonFatal(ex) =>
               decider(ex) match {
-                case Supervision.Stop    => failStage(ex)
+                case Supervision.Stop =>
+                  failStage(ex)
+                  return
                 case Supervision.Restart =>
                   restartState()
                 case Supervision.Resume =>
@@ -1130,7 +1152,9 @@ private[stream] object Collect {
           } catch {
             case NonFatal(ex) =>
               decider(ex) match {
-                case Supervision.Stop    => failStage(ex)
+                case Supervision.Stop =>
+                  failStage(ex)
+                  return
                 case Supervision.Restart =>
                   restartState()
                 case Supervision.Resume =>
@@ -1139,7 +1163,7 @@ private[stream] object Collect {
         }
 
         if (isAvailable(out)) flush()
-        if (pending == null) pull(in)
+        if (pending == null && !isClosed(in)) pull(in)
       }
 
       override def onUpstreamFinish(): Unit = {
@@ -1153,6 +1177,7 @@ private[stream] object Collect {
         } else if (isClosed(in)) {
           contextPropagation.resumeContext()
           push(out, agg)
+          agg = null.asInstanceOf[Out]
           if (pending == null) completeStage()
           else {
             try {
@@ -1160,19 +1185,21 @@ private[stream] object Collect {
             } catch {
               case NonFatal(ex) =>
                 decider(ex) match {
-                  case Supervision.Stop    => failStage(ex)
+                  case Supervision.Stop =>
+                    failStage(ex)
+                    return
                   case Supervision.Resume  =>
                   case Supervision.Restart =>
                     restartState()
-                    if (!hasBeenPulled(in)) pull(in)
                 }
             }
             pending = null.asInstanceOf[In]
+            if (agg == null) completeStage()
           }
         } else {
           contextPropagation.resumeContext()
           flush()
-          if (!hasBeenPulled(in)) pull(in)
+          if (!isClosed(in) && !hasBeenPulled(in)) pull(in)
         }
 
       }


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to