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 fdbf6a4317 feat: add supervision strategy support for delayWith (#3182)
fdbf6a4317 is described below

commit fdbf6a43174431a9ea8201042310265a7ecca484
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Sun Jun 28 02:44:33 2026 +0800

    feat: add supervision strategy support for delayWith (#3182)
    
    Motivation:
    The delayWith operator computes each element's delay via a user-provided
    DelayStrategy.nextDelay, but it was called without a try-catch. Any 
exception
    failed the stream unconditionally, ignoring the configured 
SupervisionStrategy.
    
    Modification:
    Wrap delayStrategy.nextDelay(element) in Delay.grabAndPull() with a 
try-catch
    that consults the SupervisionStrategy decider. Stop fails the stage, Resume
    drops the offending element while keeping buffered elements and the 
strategy,
    and Restart recreates the (potentially stateful) delay strategy from the
    supplier. The delayStrategy field became a var to allow recreation. Because 
a
    dropped element no longer enqueues, onPush and onTimer now guard their 
buffer
    access with a non-empty check so a skip cannot dequeue/peek an empty buffer
    (onTimer previously could NPE when an overflow handler emptied the buffer 
while
    a timer was still armed). The decider is a lazy val for zero overhead on the
    happy path. Update the delayWith Scala/Java DSL scaladoc and the operator
    reference page.
    
    Result:
    delayWith now adheres to the SupervisionStrategy attribute with well-defined
    Resume and Restart semantics, with no buffer-underflow on the supervised 
paths.
    
    Tests:
    - sbt "stream-tests/Test/testOnly 
org.apache.pekko.stream.scaladsl.FlowDelaySpec" -- 26/26 passed
    
    References:
    Refs #3110
---
 .../stream/operators/Source-or-Flow/delayWith.md   |   2 +
 .../pekko/stream/scaladsl/FlowDelaySpec.scala      | 128 +++++++++++++++++++++
 .../org/apache/pekko/stream/impl/fusing/Ops.scala  |  32 +++++-
 .../org/apache/pekko/stream/javadsl/Flow.scala     |   4 +
 .../org/apache/pekko/stream/javadsl/Source.scala   |   4 +
 .../org/apache/pekko/stream/javadsl/SubFlow.scala  |   4 +
 .../apache/pekko/stream/javadsl/SubSource.scala    |   4 +
 .../org/apache/pekko/stream/scaladsl/Flow.scala    |   4 +
 8 files changed, 178 insertions(+), 4 deletions(-)

diff --git a/docs/src/main/paradox/stream/operators/Source-or-Flow/delayWith.md 
b/docs/src/main/paradox/stream/operators/Source-or-Flow/delayWith.md
index f76efe39a0..25f361ebca 100644
--- a/docs/src/main/paradox/stream/operators/Source-or-Flow/delayWith.md
+++ b/docs/src/main/paradox/stream/operators/Source-or-Flow/delayWith.md
@@ -14,6 +14,8 @@ Delay every element passed through with a duration that can 
be controlled dynami
 
 Delay every element passed through with a duration that can be controlled 
dynamically, individually for each elements (via the `DelayStrategy`).
 
+This operator adheres to the ActorAttributes.SupervisionStrategy attribute. On 
`Supervision.Resume` the offending element is dropped; on `Supervision.Restart` 
the delay strategy is recreated from the supplier (already-buffered elements 
keep their delays).
+
 
 @@@div { .callout }
 
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowDelaySpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowDelaySpec.scala
index 42608cb9c9..e486eb98fc 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowDelaySpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowDelaySpec.scala
@@ -347,5 +347,133 @@ class FlowDelaySpec extends StreamSpec {
       probe.expectComplete()
 
     }
+
+    "fail with stop supervision when delay strategy throws" in {
+      val ex = new RuntimeException("boom-stop") with NoStackTrace
+      val failed = Source(1 to 3)
+        .delayWith(
+          () =>
+            new DelayStrategy[Int] {
+              override def nextDelay(elem: Int): FiniteDuration =
+                if (elem == 2) throw ex else Duration.Zero
+            },
+          OverflowStrategy.backpressure)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.stoppingDecider))
+        .runWith(Sink.seq)
+        .failed
+        .futureValue
+
+      failed should be(ex)
+    }
+
+    "default to stop supervision when delay strategy throws" in {
+      val ex = new RuntimeException("boom-default-stop") with NoStackTrace
+      val failed = Source(1 to 3)
+        .delayWith(
+          () =>
+            new DelayStrategy[Int] {
+              override def nextDelay(elem: Int): FiniteDuration =
+                if (elem == 2) throw ex else Duration.Zero
+            },
+          OverflowStrategy.backpressure)
+        .runWith(Sink.seq)
+        .failed
+        .futureValue
+
+      failed should be(ex)
+    }
+
+    "drop the offending element on resume supervision when delay strategy 
throws" in {
+      val ex = new RuntimeException("boom-resume") with NoStackTrace
+      val result = Source(1 to 3)
+        .delayWith(
+          () =>
+            new DelayStrategy[Int] {
+              override def nextDelay(elem: Int): FiniteDuration =
+                if (elem == 2) throw ex else Duration.Zero
+            },
+          OverflowStrategy.backpressure)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .runWith(Sink.seq)
+        .futureValue
+
+      result should ===(Seq(1, 3))
+    }
+
+    "drop the offending element and recreate stateful strategy on restart 
supervision" in {
+      val ex = new RuntimeException("boom-restart") with NoStackTrace
+      val stateNotResetEx = new RuntimeException("strategy state was not 
reset") with NoStackTrace
+      val result = Source(1 to 3)
+        .delayWith(
+          () =>
+            new DelayStrategy[Int] {
+              private var invocationCount = 0
+
+              override def nextDelay(elem: Int): FiniteDuration = {
+                invocationCount += 1
+                if (elem == 2) throw ex
+                else if (invocationCount == 1) Duration.Zero
+                else throw stateNotResetEx
+              }
+            },
+          OverflowStrategy.backpressure)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.restartingDecider))
+        .runWith(Sink.seq)
+        .futureValue
+
+      result should ===(Seq(1, 3))
+    }
+
+    "keep the stateful strategy (no reset) on resume supervision" in {
+      val ex = new RuntimeException("boom-resume-stateful") with NoStackTrace
+      val stateWasResetEx = new RuntimeException("strategy state was reset") 
with NoStackTrace
+      val result = Source(1 to 3)
+        .delayWith(
+          () =>
+            new DelayStrategy[Int] {
+              private var invocationCount = 0
+              override def nextDelay(elem: Int): FiniteDuration = {
+                invocationCount += 1
+                elem match {
+                  case 2                         => throw ex
+                  case 3 if invocationCount != 3 => throw stateWasResetEx
+                  case _                         => Duration.Zero
+                }
+              }
+            },
+          OverflowStrategy.backpressure)
+        
.withAttributes(ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .runWith(Sink.seq)
+        .futureValue
+      result should ===(Seq(1, 3))
+    }
+
+    "not crash in onTimer when an overflow handler empties the buffer and the 
strategy throws" taggedAs TimingTest in {
+      val ex = new RuntimeException("boom-overflow") with NoStackTrace
+      val upstream = TestPublisher.probe[Int]()
+      val downstream = TestSubscriber.probe[Int]()
+      Source
+        .fromPublisher(upstream)
+        .delayWith(
+          () =>
+            new DelayStrategy[Int] {
+              override def nextDelay(elem: Int): FiniteDuration =
+                if (elem == 2) throw ex else 200.millis
+            },
+          OverflowStrategy.dropBuffer)
+        .withAttributes(Attributes.inputBuffer(1, 1) and
+          ActorAttributes.supervisionStrategy(Supervision.resumingDecider))
+        .to(Sink.fromSubscriber(downstream))
+        .run()
+
+      downstream.request(10)
+      upstream.sendNext(1)
+      upstream.sendNext(2)
+      downstream.expectNoMessage(400.millis)
+      upstream.sendNext(3)
+      downstream.expectNext(500.millis, 3)
+      upstream.sendComplete()
+      downstream.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 c267659b03..fc9f3e1dda 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
@@ -2041,7 +2041,8 @@ private[stream] object Collect {
 
       private val size = 
inheritedAttributes.mandatoryAttribute[InputBuffer].max
 
-      private val delayStrategy = delayStrategySupplier()
+      private var delayStrategy = delayStrategySupplier()
+      private lazy val decider = 
inheritedAttributes.mandatoryAttribute[SupervisionStrategy].decider
 
       // buffer has pairs of timestamp of expected push and element
       private val buffer = BufferImpl[(Long, T)](size, inheritedAttributes)
@@ -2092,7 +2093,9 @@ private[stream] object Collect {
           onPushWhenBufferFull()
         else {
           grabAndPull()
-          if (!isTimerActive(TimerName)) {
+          // Resume/Restart may drop the current element, leaving the buffer 
empty.
+          // Restart also recreates the stateful strategy for subsequent 
elements.
+          if (!buffer.isEmpty && !isTimerActive(TimerName)) {
             val waitTime = nextElementWaitTime()
             if (waitTime <= DelayPrecisionMS && isAvailable(out)) {
               push(out, buffer.dequeue()._2)
@@ -2110,7 +2113,26 @@ private[stream] object Collect {
 
       private def grabAndPull(): Unit = {
         val element = grab(in)
-        buffer.enqueue((System.nanoTime() + 
delayStrategy.nextDelay(element).toNanos, element))
+        val delay =
+          try delayStrategy.nextDelay(element)
+          catch {
+            case NonFatal(ex) =>
+              decider(ex) match {
+                case Supervision.Stop =>
+                  failStage(ex)
+                  return
+                case Supervision.Resume =>
+                  // Skip only the offending element, keep buffered elements 
and current strategy state.
+                  if (shouldPull) pull(in)
+                  return
+                case Supervision.Restart =>
+                  // Skip offending element and recreate the potentially 
stateful strategy.
+                  delayStrategy = delayStrategySupplier()
+                  if (shouldPull) pull(in)
+                  return
+              }
+          }
+        buffer.enqueue((System.nanoTime() + delay.toNanos, element))
         if (shouldPull) pull(in)
       }
 
@@ -2142,7 +2164,9 @@ private[stream] object Collect {
       }
 
       final override protected def onTimer(key: Any): Unit = {
-        if (isAvailable(out))
+        // A Resume/Restart skip (possibly via an overflow handler that 
already mutated the buffer) can
+        // leave the buffer empty while this timer is still armed; guard 
against dequeuing an empty buffer.
+        if (isAvailable(out) && !buffer.isEmpty)
           push(out, buffer.dequeue()._2)
 
         completeIfReady()
diff --git a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala
index 1fda31b964..cf3119f85b 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Flow.scala
@@ -1794,6 +1794,10 @@ final class Flow[In, Out, Mat](delegate: 
scaladsl.Flow[In, Out, Mat]) extends Gr
    *
    * Internal buffer has default capacity 16. You can set buffer size by 
calling `addAttributes(inputBuffer)`
    *
+   * Adheres to the [[ActorAttributes.SupervisionStrategy]] attribute. On 
`Supervision.Resume` the
+   * offending element is dropped; on `Supervision.Restart` the delay strategy 
is recreated from the
+   * supplier (already-buffered elements keep their delays).
+   *
    * '''Emits when''' there is a pending element in the buffer and configured 
time for this element elapsed
    *  * EmitEarly - strategy do not wait to emit element if buffer is full
    *
diff --git a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala
index e889fbf5ea..7dd27fbe52 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/Source.scala
@@ -3678,6 +3678,10 @@ final class Source[Out, Mat](delegate: 
scaladsl.Source[Out, Mat]) extends Graph[
    *
    * Internal buffer has default capacity 16. You can set buffer size by 
calling `addAttributes(inputBuffer)`
    *
+   * Adheres to the [[ActorAttributes.SupervisionStrategy]] attribute. On 
`Supervision.Resume` the
+   * offending element is dropped; on `Supervision.Restart` the delay strategy 
is recreated from the
+   * supplier (already-buffered elements keep their delays).
+   *
    * '''Emits when''' there is a pending element in the buffer and configured 
time for this element elapsed
    *  * EmitEarly - strategy do not wait to emit element if buffer is full
    *
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala
index e54eebec08..ce766b5628 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubFlow.scala
@@ -1188,6 +1188,10 @@ final class SubFlow[In, Out, Mat](
    *
    * Internal buffer has default capacity 16. You can set buffer size by 
calling `addAttributes(inputBuffer)`
    *
+   * Adheres to the [[ActorAttributes.SupervisionStrategy]] attribute. On 
`Supervision.Resume` the
+   * offending element is dropped; on `Supervision.Restart` the delay strategy 
is recreated from the
+   * supplier (already-buffered elements keep their delays).
+   *
    * '''Emits when''' there is a pending element in the buffer and configured 
time for this element elapsed
    *  * EmitEarly - strategy do not wait to emit element if buffer is full
    *
diff --git 
a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala 
b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala
index 5e16f5d39f..67d339a1c0 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/javadsl/SubSource.scala
@@ -1280,6 +1280,10 @@ final class SubSource[Out, Mat](
    *
    * Internal buffer has default capacity 16. You can set buffer size by 
calling `addAttributes(inputBuffer)`
    *
+   * Adheres to the [[ActorAttributes.SupervisionStrategy]] attribute. On 
`Supervision.Resume` the
+   * offending element is dropped; on `Supervision.Restart` the delay strategy 
is recreated from the
+   * supplier (already-buffered elements keep their delays).
+   *
    * '''Emits when''' there is a pending element in the buffer and configured 
time for this element elapsed
    *  * EmitEarly - strategy do not wait to emit element if buffer is full
    *
diff --git a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala 
b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala
index 6e3c0ffe4d..cbcb86432f 100755
--- a/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/scaladsl/Flow.scala
@@ -2295,6 +2295,10 @@ trait FlowOps[+Out, +Mat] {
    *
    * Internal buffer has default capacity 16. You can set buffer size by 
calling `addAttributes(inputBuffer)`
    *
+   * Adheres to the [[ActorAttributes.SupervisionStrategy]] attribute. On 
`Supervision.Resume` the
+   * offending element is dropped; on `Supervision.Restart` the delay strategy 
is recreated from the
+   * supplier (already-buffered elements keep their delays).
+   *
    * '''Emits when''' there is a pending element in the buffer and configured 
time for this element elapsed
    *  * EmitEarly - strategy do not wait to emit element if buffer is full
    *


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

Reply via email to