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 1ba7efbe86 fix: initialize timer stage deadlines in preStart instead 
of at construction (#3111)
1ba7efbe86 is described below

commit 1ba7efbe86161efaba4faac82fcab0e8b247fc2b
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Tue Jun 23 23:18:45 2026 +0800

    fix: initialize timer stage deadlines in preStart instead of at 
construction (#3111)
    
    * fix: initialize timer stage deadlines in preStart instead of at 
construction
    
    Motivation:
    IdleInject, Idle, BackpressureTimeout, and IdleTimeoutBidi all initialized
    `nextDeadline` at GraphStageLogic construction time
    (`System.nanoTime + timeout.toNanos`), not in preStart(). If materialization
    takes longer than the configured timeout, the first timer check triggers a
    spurious timeout even though no actual idle period has occurred.
    
    Modification:
    Move `nextDeadline` initialization from construction time to preStart() in 
all
    four timer stages: IdleInject, Idle, BackpressureTimeout, and 
IdleTimeoutBidi.
    The deadline is now set to `System.nanoTime + timeout.toNanos` inside 
preStart(),
    ensuring the timeout window starts when the stage actually begins 
processing.
    
    Result:
    Timer stages no longer risk spurious timeouts from delayed materialization.
    The deadline is always measured from the actual stream start time.
    
    Tests:
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.scaladsl.FlowIdleInjectSpec" — 10/10 passed
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.impl.TimeoutsSpec" — 25/25 passed
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.javadsl.SourceTest" — 109/109 passed
    - sbt "stream-tests / Test / testOnly 
org.apache.pekko.stream.javadsl.FlowTest" — 88/88 passed
    
    References:
    Fixes #3105
    
    * fix: improve timer stage fix per review feedback
    
    - Use Long.MaxValue instead of 0L as fail-safe initial value for
      nextDeadline, preventing spurious timeouts if preStart() is skipped
    - Add concurrent stress tests for IdleInject and IdleTimeout that
      exercise delayed materialization scenarios
    - Increase FlowIdleInjectSpec timing margin from 100ms to 200ms to
      reduce CI flakiness
    
    References: Fixes #3105
    
    * style: apply scalafmt to TimeoutsSpec
---
 .../apache/pekko/stream/impl/TimeoutsSpec.scala    | 93 ++++++++++++++++++++++
 .../pekko/stream/scaladsl/FlowIdleInjectSpec.scala | 42 ++++++++++
 .../org/apache/pekko/stream/impl/Timers.scala      | 25 ++++--
 3 files changed, 152 insertions(+), 8 deletions(-)

diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/TimeoutsSpec.scala 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/TimeoutsSpec.scala
index a536c83199..3e2ac42cc9 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/impl/TimeoutsSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/impl/TimeoutsSpec.scala
@@ -131,6 +131,49 @@ class TimeoutsSpec extends StreamSpec {
       ex.getMessage should ===("No elements passed in the last 1 second.")
     }
 
+    "not fail before timeout elapses from stream start" in {
+      val upstreamProbe = TestPublisher.probe[Int]()
+      val downstreamProbe = TestSubscriber.probe[Int]()
+      val timeout = 1.second
+
+      
Source.fromPublisher(upstreamProbe).idleTimeout(timeout).runWith(Sink.fromSubscriber(downstreamProbe))
+
+      // After materialization, the deadline should be set relative to 
preStart,
+      // not construction time. No timeout error should occur within the 
timeout period.
+      downstreamProbe.request(1)
+      downstreamProbe.expectNoMessage(timeout - 200.millis)
+
+      // Send an element to keep the stream alive, then verify no premature 
timeout
+      upstreamProbe.sendNext(42)
+      downstreamProbe.expectNext(42)
+
+      upstreamProbe.sendComplete()
+      downstreamProbe.expectComplete()
+    }
+
+    "not fail prematurely when materialization is delayed under load" in {
+      import system.dispatcher
+      val timeout = 300.millis
+      val streamCount = 30
+
+      // Run many streams concurrently to create materializer scheduling 
pressure.
+      // Source.maybe never emits, so the idle timeout should fire after 
~timeout.
+      val futures = (1 to streamCount).map { _ =>
+        Source.maybe[Int]
+          .idleTimeout(timeout)
+          .recover { case _: StreamIdleTimeoutException => -1 }
+          .takeWithin(timeout * 4)
+          .runWith(Sink.headOption)
+      }
+
+      val results = Await.result(Future.sequence(futures), 10.seconds)
+      // Each stream should get -1 (idle timeout after proper wait) or None 
(takeWithin)
+      // It should NOT fail immediately upon materialization
+      results.foreach { result =>
+        result should (be(Some(-1)).or(be(None)))
+      }
+    }
+
   }
 
   "BackpressureTimeout" must {
@@ -232,6 +275,23 @@ class TimeoutsSpec extends StreamSpec {
       subscriber.expectComplete()
     }
 
+    "not fail before timeout elapses from stream start" in {
+      val publisher = TestPublisher.probe[Int]()
+      val subscriber = TestSubscriber.probe[Int]()
+      val timeout = 1.second
+
+      
Source.fromPublisher(publisher).backpressureTimeout(timeout).runWith(Sink.fromSubscriber(subscriber))
+
+      // After materialization, the deadline should be set relative to 
preStart,
+      // not construction time. No timeout error should occur within the 
timeout period
+      // even without any demand from downstream.
+      subscriber.ensureSubscription()
+      subscriber.expectNoMessage(timeout - 200.millis)
+
+      publisher.sendComplete()
+      subscriber.expectComplete()
+    }
+
   }
 
   "IdleTimeoutBidi" must {
@@ -348,6 +408,39 @@ class TimeoutsSpec extends StreamSpec {
       downWrite.expectCancellation()
     }
 
+    "not fail before timeout elapses from stream start" in {
+      val upWrite = TestPublisher.probe[String]()
+      val upRead = TestSubscriber.probe[Int]()
+      val downWrite = TestPublisher.probe[Int]()
+      val downRead = TestSubscriber.probe[String]()
+      val timeout = 1.second
+
+      RunnableGraph
+        .fromGraph(GraphDSL.create() { implicit b =>
+          import GraphDSL.Implicits._
+          val timeoutStage = b.add(BidiFlow.bidirectionalIdleTimeout[String, 
Int](timeout))
+          Source.fromPublisher(upWrite) ~> timeoutStage.in1
+          timeoutStage.out1             ~> Sink.fromSubscriber(downRead)
+          Sink.fromSubscriber(upRead) <~ timeoutStage.out2
+          timeoutStage.in2 <~ Source.fromPublisher(downWrite)
+          ClosedShape
+        })
+        .run()
+
+      upRead.request(1)
+      downRead.request(1)
+
+      // After materialization, the deadline should be set relative to 
preStart,
+      // not construction time. No timeout error should occur within the 
timeout period.
+      upRead.expectNoMessage(timeout - 200.millis)
+      downRead.expectNoMessage(0.millis)
+
+      upWrite.sendComplete()
+      downWrite.sendComplete()
+      upRead.expectComplete()
+      downRead.expectComplete()
+    }
+
   }
 
   "Subscription timeouts" must {
diff --git 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowIdleInjectSpec.scala
 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowIdleInjectSpec.scala
index ac100265b5..5a3b937e99 100644
--- 
a/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowIdleInjectSpec.scala
+++ 
b/stream-tests/src/test/scala/org/apache/pekko/stream/scaladsl/FlowIdleInjectSpec.scala
@@ -14,6 +14,7 @@
 package org.apache.pekko.stream.scaladsl
 
 import scala.concurrent.Await
+import scala.concurrent.Future
 import scala.concurrent.duration._
 
 import org.apache.pekko
@@ -161,6 +162,47 @@ class FlowIdleInjectSpec extends StreamSpec("""
       downstream.expectNext(0)
     }
 
+    "not inject idle element before timeout elapses from stream start" in {
+      val upstream = TestPublisher.probe[Int]()
+      val downstream = TestSubscriber.probe[Int]()
+      val timeout = 500.millis
+
+      Source.fromPublisher(upstream).keepAlive(timeout, () => 
0).runWith(Sink.fromSubscriber(downstream))
+
+      downstream.ensureSubscription()
+      // No idle element should appear before the timeout period, even though
+      // the stage is constructed before preStart runs.
+      downstream.expectNoMessage(timeout - 200.millis)
+
+      // After timeout, the injected element should arrive
+      downstream.request(1)
+      downstream.expectNext(0)
+
+      upstream.sendComplete()
+      downstream.expectComplete()
+    }
+
+    "not inject prematurely when materialization is delayed under load" in {
+      import system.dispatcher
+      val timeout = 300.millis
+      val streamCount = 30
+
+      // Run many streams concurrently to create materializer scheduling 
pressure,
+      // increasing the chance of delay between createLogic and preStart.
+      val futures = (1 to streamCount).map { _ =>
+        Source.maybe[Int]
+          .keepAlive(timeout, () => 0)
+          .takeWithin(timeout * 4)
+          .runWith(Sink.headOption)
+      }
+
+      val results = Await.result(Future.sequence(futures), 10.seconds)
+      // Each stream should receive the injected element 0 after ~timeout
+      results.foreach { result =>
+        result shouldBe Some(0)
+      }
+    }
+
   }
 
 }
diff --git a/stream/src/main/scala/org/apache/pekko/stream/impl/Timers.scala 
b/stream/src/main/scala/org/apache/pekko/stream/impl/Timers.scala
index cdbc42828f..f644a958e7 100644
--- a/stream/src/main/scala/org/apache/pekko/stream/impl/Timers.scala
+++ b/stream/src/main/scala/org/apache/pekko/stream/impl/Timers.scala
@@ -105,7 +105,7 @@ import pekko.stream.stage._
 
     override def createLogic(inheritedAttributes: Attributes): GraphStageLogic 
=
       new TimerGraphStageLogic(shape) with InHandler with OutHandler {
-        private var nextDeadline: Long = System.nanoTime + timeout.toNanos
+        private var nextDeadline: Long = Long.MaxValue
 
         setHandlers(in, out, this)
 
@@ -120,8 +120,10 @@ import pekko.stream.stage._
           if (nextDeadline - System.nanoTime < 0)
             failStage(new StreamIdleTimeoutException(s"No elements passed in 
the last ${timeout.toCoarsest}."))
 
-        override def preStart(): Unit =
+        override def preStart(): Unit = {
+          nextDeadline = System.nanoTime + timeout.toNanos
           scheduleWithFixedDelay(GraphStageLogicTimer, 
timeoutCheckInterval(timeout), timeoutCheckInterval(timeout))
+        }
       }
 
     override def toString = "IdleTimeout"
@@ -133,7 +135,7 @@ import pekko.stream.stage._
 
     override def createLogic(inheritedAttributes: Attributes): GraphStageLogic 
=
       new TimerGraphStageLogic(shape) with InHandler with OutHandler {
-        private var nextDeadline: Long = System.nanoTime + timeout.toNanos
+        private var nextDeadline: Long = Long.MaxValue
         private var waitingDemand: Boolean = true
 
         setHandlers(in, out, this)
@@ -153,8 +155,10 @@ import pekko.stream.stage._
           if (waitingDemand && (nextDeadline - System.nanoTime < 0))
             failStage(new BackpressureTimeoutException(s"No demand signalled 
in the last ${timeout.toCoarsest}."))
 
-        override def preStart(): Unit =
+        override def preStart(): Unit = {
+          nextDeadline = System.nanoTime + timeout.toNanos
           scheduleWithFixedDelay(GraphStageLogicTimer, 
timeoutCheckInterval(timeout), timeoutCheckInterval(timeout))
+        }
       }
 
     override def toString = "BackpressureTimeout"
@@ -171,7 +175,7 @@ import pekko.stream.stage._
     override def initialAttributes = DefaultAttributes.idleTimeoutBidi
 
     override def createLogic(inheritedAttributes: Attributes): GraphStageLogic 
= new TimerGraphStageLogic(shape) {
-      private var nextDeadline: Long = System.nanoTime + timeout.toNanos
+      private var nextDeadline: Long = Long.MaxValue
 
       setHandlers(in1, out1, new IdleBidiHandler(in1, out1))
       setHandlers(in2, out2, new IdleBidiHandler(in2, out2))
@@ -182,8 +186,10 @@ import pekko.stream.stage._
         if (nextDeadline - System.nanoTime < 0)
           failStage(new StreamIdleTimeoutException(s"No elements passed in the 
last ${timeout.toCoarsest}."))
 
-      override def preStart(): Unit =
+      override def preStart(): Unit = {
+        nextDeadline = System.nanoTime + timeout.toNanos
         scheduleWithFixedDelay(GraphStageLogicTimer, 
timeoutCheckInterval(timeout), timeoutCheckInterval(timeout))
+      }
 
       class IdleBidiHandler[P](in: Inlet[P], out: Outlet[P]) extends InHandler 
with OutHandler {
         override def onPush(): Unit = {
@@ -240,13 +246,16 @@ import pekko.stream.stage._
 
     override def createLogic(inheritedAttributes: Attributes): GraphStageLogic 
=
       new TimerGraphStageLogic(shape) with StageLogging with InHandler with 
OutHandler {
-        private var nextDeadline: Long = System.nanoTime + timeout.toNanos
+        private var nextDeadline: Long = Long.MaxValue
         private val contextPropagation = ContextPropagation()
 
         setHandlers(in, out, this)
 
         // Prefetching to ensure priority of actual upstream elements
-        override def preStart(): Unit = pull(in)
+        override def preStart(): Unit = {
+          nextDeadline = System.nanoTime + timeout.toNanos
+          pull(in)
+        }
 
         override def onPush(): Unit = {
           nextDeadline = System.nanoTime + timeout.toNanos


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

Reply via email to