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 c5132df18f fix: bound persistence event replay batches (#3325)
c5132df18f is described below

commit c5132df18f383349d58a9015975238de346059a8
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Thu Jul 16 23:01:56 2026 +0800

    fix: bound persistence event replay batches (#3325)
    
    Motivation:
    AsyncWriteJournal can enqueue an unbounded number of replayed events in a 
recovering actor mailbox and exhaust memory.
    
    Modification:
    Add acknowledged, configurable replay batching for classic and typed 
persistence while preserving replay-filter state, sender ordering, restart 
isolation, and plugin compatibility.
    
    Result:
    Recovery limits in-flight replay events without truncating recovered 
history.
    
    Tests:
    - Focused classic protocol, replay filter, and replay batching tests: 24 
passed
    - Focused typed replay batching tests: 2 passed
    - sbt "persistence / Test / test": 63 passed; LevelDB JNI emitted an Apple 
ARM64/x86 architecture warning
    - sbt "persistence-typed-tests / Test / test": 204 passed
    - sbt "persistence-shared / Test / testOnly 
org.apache.pekko.persistence.journal.leveldb.PersistencePluginProxySpec": 1 
passed
    - sbt headerCreateAll, +headerCheckAll, checkCodeStyle, docs/paradox, and 
+mimaReportBinaryIssues: passed; Paradox reported duplicate-anchor warnings
    - scalafmt --list --mode diff-ref=origin/main and git diff --check: passed
    - sbt sortImports: environment/task failure; the project-scoped key was 
unavailable and root scalafix raised NoSuchMethodError
    - sbt validatePullRequest: failed after 28m26s because LevelDB JNI 1.8 is 
x86-only on macOS ARM64 and unrelated docs tests failed (UnboundedMailbox 
classpath and UDP multicast interface timeouts)
    
    References:
    Fixes #3241
---
 docs/src/main/paradox/persistence.md               |  11 +
 docs/src/main/paradox/typed/persistence.md         |  11 +
 .../leveldb/PersistencePluginProxySpec.scala       |  14 +-
 .../EventSourcedBehaviorReplayBatchingSpec.scala   | 243 +++++++++++++
 .../typed/internal/ExternalInteractions.scala      |   3 +
 .../typed/internal/ReplayingEvents.scala           |  10 +
 persistence/src/main/resources/reference.conf      |   5 +
 .../apache/pekko/persistence/Eventsourced.scala    |  13 +-
 .../apache/pekko/persistence/JournalProtocol.scala |  35 ++
 .../persistence/journal/AsyncWriteJournal.scala    | 316 ++++++++++++++---
 .../journal/PersistencePluginProxy.scala           |   4 +-
 .../pekko/persistence/journal/ReplayFilter.scala   |  10 +-
 .../PersistentActorJournalProtocolSpec.scala       |   9 +-
 .../PersistentActorReplayBatchingSpec.scala        | 387 +++++++++++++++++++++
 .../persistence/journal/ReplayFilterSpec.scala     |  27 ++
 15 files changed, 1043 insertions(+), 55 deletions(-)

diff --git a/docs/src/main/paradox/persistence.md 
b/docs/src/main/paradox/persistence.md
index 900f6d2795..36d0f92514 100644
--- a/docs/src/main/paradox/persistence.md
+++ b/docs/src/main/paradox/persistence.md
@@ -136,6 +136,17 @@ until other recoveries have been completed. This is 
configured by:
 pekko.persistence.max-concurrent-recoveries = 50
 ```
 
+Journal plugins based on `AsyncWriteJournal` also bound each individual 
recovery. They replay at most
+`replay-batch-size` event sequence numbers and wait until the recovering actor 
has processed that batch before
+requesting the next one. The default is `1000`; it can be changed in the 
journal plugin configuration, for example:
+
+```
+pekko.persistence.journal.my-plugin.replay-batch-size = 500
+```
+
+This setting limits replay pressure on the persistent actor's mailbox without 
limiting the total number of events
+that are recovered.
+
 @@@ note
 
 Accessing the @scala[`sender()`]@java[sender with `getSender()`] for replayed 
messages will always result in a `deadLetters` reference,
diff --git a/docs/src/main/paradox/typed/persistence.md 
b/docs/src/main/paradox/typed/persistence.md
index 3e4c2aea65..172067f0bb 100644
--- a/docs/src/main/paradox/typed/persistence.md
+++ b/docs/src/main/paradox/typed/persistence.md
@@ -468,6 +468,17 @@ until other recoveries have been completed. This is 
configured by:
 pekko.persistence.max-concurrent-recoveries = 50
 ```
 
+Journal plugins based on `AsyncWriteJournal` also bound each individual 
recovery. They replay at most
+`replay-batch-size` event sequence numbers and wait until the recovering actor 
has processed that batch before
+requesting the next one. The default is `1000`; it can be changed in the 
journal plugin configuration, for example:
+
+```
+pekko.persistence.journal.my-plugin.replay-batch-size = 500
+```
+
+This setting limits replay pressure on the persistent actor's mailbox without 
limiting the total number of events
+that are recovered.
+
 The @ref:[event handler](#event-handler) is used for updating the state when 
replaying the journaled events.
 
 It is strongly discouraged to perform side effects in the event handler, so 
side effects should be performed
diff --git 
a/persistence-shared/src/test/scala/org/apache/pekko/persistence/journal/leveldb/PersistencePluginProxySpec.scala
 
b/persistence-shared/src/test/scala/org/apache/pekko/persistence/journal/leveldb/PersistencePluginProxySpec.scala
index 3364cfef33..01baf79c63 100644
--- 
a/persistence-shared/src/test/scala/org/apache/pekko/persistence/journal/leveldb/PersistencePluginProxySpec.scala
+++ 
b/persistence-shared/src/test/scala/org/apache/pekko/persistence/journal/leveldb/PersistencePluginProxySpec.scala
@@ -32,6 +32,10 @@ object PersistencePluginProxySpec {
           journal {
             plugin = "pekko.persistence.journal.proxy"
             proxy.target-journal-plugin = "pekko.persistence.journal.inmem"
+            inmem {
+              replay-batch-size = 2
+              replay-filter.mode = off
+            }
           }
           snapshot-store {
             plugin = "pekko.persistence.snapshot-store.proxy"
@@ -131,20 +135,20 @@ class PersistencePluginProxySpec
       val appA = systemA.actorOf(Props(classOf[ExampleApp], probeA.ref))
       val appB = systemB.actorOf(Props(classOf[ExampleApp], probeB.ref))
 
-      appA ! "a1"
+      val eventsA = Vector("a1", "a2", "a3", "a4", "a5")
+      eventsA.foreach(appA ! _)
       appB ! "b1"
 
-      probeA.expectMsg("a1")
+      eventsA.foreach(event => probeA.expectMsg(event))
       probeB.expectMsg("b1")
 
       val recoveredAppA = systemA.actorOf(Props(classOf[ExampleApp], 
probeA.ref))
       val recoveredAppB = systemB.actorOf(Props(classOf[ExampleApp], 
probeB.ref))
 
-      recoveredAppA ! "a2"
+      recoveredAppA ! "a6"
       recoveredAppB ! "b2"
 
-      probeA.expectMsg("a1")
-      probeA.expectMsg("a2")
+      (eventsA :+ "a6").foreach(event => probeA.expectMsg(event))
 
       probeB.expectMsg("b1")
       probeB.expectMsg("b2")
diff --git 
a/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorReplayBatchingSpec.scala
 
b/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorReplayBatchingSpec.scala
new file mode 100644
index 0000000000..ba8b0726d0
--- /dev/null
+++ 
b/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorReplayBatchingSpec.scala
@@ -0,0 +1,243 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.persistence.typed.scaladsl
+
+import scala.concurrent.{ Await, Future, Promise }
+import scala.concurrent.duration._
+
+import org.apache.pekko
+import pekko.actor.Actor
+import pekko.actor.testkit.typed.scaladsl.{ LogCapturing, 
ScalaTestWithActorTestKit }
+import pekko.actor.typed.{ ActorRef, Behavior, PreRestart, SupervisorStrategy }
+import pekko.persistence.JournalProtocol.{ ReplayBatchResponse, 
ReplayedMessage }
+import pekko.persistence.PersistentRepr
+import pekko.persistence.journal.SteppingInmemJournal
+import pekko.persistence.journal.inmem.InmemJournal
+import pekko.persistence.typed.{ PersistenceId, RecoveryCompleted, 
RecoveryFailed }
+import pekko.persistence.typed.internal.EventSourcedBehaviorImpl.WriterIdentity
+
+import org.scalatest.wordspec.AnyWordSpecLike
+
+import com.typesafe.config.ConfigFactory
+
+object DelayedReplayInmemJournal {
+  private var plannedInvocations = 0
+  private var nextInvocation = 0
+  private var started = Vector.empty[Promise[Unit]]
+  private var completions = Map.empty[Int, () => Unit]
+
+  def prepare(invocations: Int): Vector[Future[Unit]] = synchronized {
+    require(nextInvocation == plannedInvocations && completions.isEmpty, 
"previous delayed replay is still active")
+    plannedInvocations = invocations
+    nextInvocation = 0
+    started = Vector.fill(invocations)(Promise[Unit]())
+    started.map(_.future)
+  }
+
+  private def claim(): Option[Int] = synchronized {
+    if (nextInvocation == plannedInvocations) None
+    else {
+      val invocation = nextInvocation
+      nextInvocation += 1
+      Some(invocation)
+    }
+  }
+
+  private def register(invocation: Int, completion: () => Unit): Unit = 
synchronized {
+    completions = completions.updated(invocation, completion)
+    started(invocation).success(())
+  }
+
+  def complete(invocation: Int): Unit = {
+    val completion = synchronized {
+      val result = completions.getOrElse(invocation, throw new 
IllegalStateException("delayed replay has not started"))
+      completions -= invocation
+      result
+    }
+    completion()
+  }
+}
+
+final class DelayedReplayInmemJournal extends InmemJournal {
+  import DelayedReplayInmemJournal._
+  import context.dispatcher
+
+  override def asyncReplayMessages(persistenceId: String, fromSequenceNr: 
Long, toSequenceNr: Long, max: Long)(
+      recoveryCallback: PersistentRepr => Unit): Future[Unit] =
+    claim() match {
+      case None             => super.asyncReplayMessages(persistenceId, 
fromSequenceNr, toSequenceNr, max)(recoveryCallback)
+      case Some(invocation) =>
+        val buffered = Vector.newBuilder[PersistentRepr]
+        super
+          .asyncReplayMessages(persistenceId, fromSequenceNr, toSequenceNr, 
max)(buffered += _)
+          .flatMap { _ =>
+            val promise = Promise[Unit]()
+            val replayed = buffered.result()
+            register(
+              invocation,
+              () => {
+                replayed.foreach(recoveryCallback)
+                promise.success(())
+              })
+            promise.future
+          }
+    }
+}
+
+object EventSourcedBehaviorReplayBatchingSpec {
+  val JournalId = "event-sourced-behavior-replay-batching-spec"
+
+  sealed trait Command
+  final case class PersistAll(events: Vector[String]) extends Command
+  final case class Applied(event: String)
+  final case class RecoveryFinished(state: Vector[String])
+  case object RecoveryFailedObserved
+  case object Restarting
+
+  val config =
+    SteppingInmemJournal
+      .config(JournalId)
+      .withFallback(ConfigFactory.parseString(s"""
+          pekko.persistence.journal.stepping-inmem {
+            replay-batch-size = 2
+            replay-filter.mode = off
+          }
+          delayed-replay-journal = $${pekko.persistence.journal.inmem}
+          delayed-replay-journal {
+            class = 
"org.apache.pekko.persistence.typed.scaladsl.DelayedReplayInmemJournal"
+            replay-batch-size = 2
+            replay-filter.mode = off
+            recovery-event-timeout = 1s
+          }
+        """)).withFallback(ConfigFactory.defaultReference()).resolve()
+
+  def behavior(persistenceId: PersistenceId, probe: ActorRef[AnyRef]): 
Behavior[Command] =
+    EventSourcedBehavior[Command, String, Vector[String]](
+      persistenceId,
+      emptyState = Vector.empty,
+      commandHandler = (_, command) =>
+        command match {
+          case PersistAll(events) => Effect.persist(events)
+        },
+      eventHandler = (state, event) => {
+        probe ! Applied(event)
+        state :+ event
+      }).receiveSignal {
+      case (state, RecoveryCompleted) => probe ! RecoveryFinished(state)
+    }
+
+  def restartingBehavior(persistenceId: PersistenceId, probe: 
ActorRef[AnyRef]): Behavior[Command] =
+    EventSourcedBehavior[Command, String, Vector[String]](
+      persistenceId,
+      emptyState = Vector.empty,
+      commandHandler = (_, command) =>
+        command match {
+          case PersistAll(events) => Effect.persist(events)
+        },
+      eventHandler = (state, event) => {
+        probe ! Applied(event)
+        state :+ event
+      }).withJournalPluginId("delayed-replay-journal").receiveSignal {
+      case (state, RecoveryCompleted) => probe ! RecoveryFinished(state)
+      case (_, RecoveryFailed(_))     => probe ! RecoveryFailedObserved
+      case (_, PreRestart)            => probe ! Restarting
+    }.onPersistFailure(
+      SupervisorStrategy.restartWithBackoff(10.millis, 10.millis, randomFactor 
= 0.0).withLoggingEnabled(false))
+}
+
+class EventSourcedBehaviorReplayBatchingSpec
+    extends 
ScalaTestWithActorTestKit(EventSourcedBehaviorReplayBatchingSpec.config)
+    with AnyWordSpecLike
+    with LogCapturing {
+  import EventSourcedBehaviorReplayBatchingSpec._
+
+  import org.apache.pekko.actor.typed.scaladsl.adapter._
+  private implicit val classicSystem: pekko.actor.ActorSystem = 
system.toClassic
+
+  "An EventSourcedBehavior recovery" must {
+    "acknowledge each bounded replay batch before the journal produces the 
next batch" in {
+      val probe = createTestProbe[AnyRef]()
+      val persistenceId = PersistenceId.ofUniqueId("bounded-replay")
+      val events = Vector("a", "b", "c", "d", "e")
+
+      val persisting = spawn(behavior(persistenceId, probe.ref))
+      probe.awaitAssert(SteppingInmemJournal.getRef(JournalId), 3.seconds)
+      val journal = SteppingInmemJournal.getRef(JournalId)
+
+      SteppingInmemJournal.step(journal)
+      probe.expectMessage(RecoveryFinished(Vector.empty))
+
+      persisting ! PersistAll(events)
+      SteppingInmemJournal.step(journal)
+      events.foreach(event => probe.expectMessage(Applied(event)))
+
+      testKit.stop(persisting)
+      probe.expectTerminated(persisting)
+
+      spawn(behavior(persistenceId, probe.ref))
+      SteppingInmemJournal.step(journal) // read highest sequence number
+
+      SteppingInmemJournal.step(journal) // replay 1-2
+      probe.expectMessage(Applied("a"))
+      probe.expectMessage(Applied("b"))
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay 3-4
+      probe.expectMessage(Applied("c"))
+      probe.expectMessage(Applied("d"))
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay 5 and complete
+      probe.expectMessage(Applied("e"))
+      probe.expectMessage(RecoveryFinished(events))
+    }
+
+    "isolate a timed-out replay from a new recovery using the same actor 
reference" in {
+      val probe = createTestProbe[AnyRef]()
+      val persistenceId = PersistenceId.ofUniqueId("timed-out-replay")
+      val events = Vector("a", "b", "c")
+
+      val persisting = spawn(restartingBehavior(persistenceId, probe.ref))
+      probe.expectMessage(RecoveryFinished(Vector.empty))
+      persisting ! PersistAll(events)
+      events.foreach(event => probe.expectMessage(Applied(event)))
+      testKit.stop(persisting)
+      probe.expectTerminated(persisting)
+
+      val replayStarted = DelayedReplayInmemJournal.prepare(invocations = 2)
+      val recovering = spawn(restartingBehavior(persistenceId, probe.ref))
+      Await.result(replayStarted.head, 3.seconds)
+      val timedOutActorInstanceId = WriterIdentity.instanceIdCounter.get() - 1
+      probe.expectMessageType[RecoveryFailedObserved.type](5.seconds)
+      probe.expectMessage(Restarting)
+      Await.result(replayStarted(1), 3.seconds)
+
+      recovering.toClassic.tell(
+        ReplayBatchResponse(
+          timedOutActorInstanceId,
+          ReplayedMessage(PersistentRepr("stale", sequenceNr = 1L, 
persistenceId = persistenceId.id))),
+        Actor.noSender)
+      DelayedReplayInmemJournal.complete(0)
+      probe.expectNoMessage(100.millis)
+
+      DelayedReplayInmemJournal.complete(1)
+      events.foreach(event => probe.expectMessage(Applied(event)))
+      probe.expectMessage(RecoveryFinished(events))
+    }
+  }
+}
diff --git 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ExternalInteractions.scala
 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ExternalInteractions.scala
index 10f8b6e497..134fe4c4eb 100644
--- 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ExternalInteractions.scala
+++ 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ExternalInteractions.scala
@@ -135,6 +135,9 @@ private[pekko] trait JournalInteractions[C, E, S] {
 
   protected def replayEvents(fromSeqNr: Long, toSeqNr: Long): Unit = {
     setup.internalLogger.debug2("Replaying events: from: {}, to: {}", 
fromSeqNr, toSeqNr)
+    setup.journal.tell(
+      
JournalProtocol.ReplayMessagesWithBatching(setup.writerIdentity.instanceId),
+      setup.selfClassic)
     setup.journal.tell(
       ReplayMessages(fromSeqNr, toSeqNr, setup.recovery.replayMax, 
setup.persistenceId.id, setup.selfClassic),
       setup.selfClassic)
diff --git 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ReplayingEvents.scala
 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ReplayingEvents.scala
index db902d9bb6..50abf44347 100644
--- 
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ReplayingEvents.scala
+++ 
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/ReplayingEvents.scala
@@ -131,6 +131,10 @@ private[pekko] final class ReplayingEvents[C, E, S](
   private def onJournalResponse(response: JournalProtocol.Response): 
Behavior[InternalProtocol] = {
     try {
       response match {
+        case ReplayBatchResponse(actorInstanceId, currentResponse) =>
+          if (actorInstanceId == setup.writerIdentity.instanceId) 
onJournalResponse(currentResponse)
+          else this
+
         case ReplayedMessage(repr) =>
           var eventForErrorReporting: OptionVal[Any] = OptionVal.None
           try {
@@ -192,6 +196,11 @@ private[pekko] final class ReplayingEvents[C, E, S](
               onRecoveryFailure(ex, eventForErrorReporting.toOption)
           }
 
+        case ReplayBatchReady(replayId) =>
+          state = state.copy(eventSeenInInterval = true)
+          setup.journal.tell(ReplayBatchAck(replayId), setup.selfClassic)
+          this
+
         case RecoverySuccess(highestJournalSeqNr) =>
           val highestSeqNr = Math.max(highestJournalSeqNr, state.seqNr)
           state = state.copy(seqNr = highestSeqNr)
@@ -255,6 +264,7 @@ private[pekko] final class ReplayingEvents[C, E, S](
    * @param event the event that was being processed when the exception was 
thrown
    */
   private def onRecoveryFailure(cause: Throwable, event: Option[Any]): 
Behavior[InternalProtocol] = {
+    setup.journal.tell(ReplayMessagesCancel, setup.selfClassic)
     onRecoveryFailed(setup.context, cause, event)
     setup.onSignal(state.state, RecoveryFailed(cause), catchAndLog = true)
     setup.cancelRecoveryTimer()
diff --git a/persistence/src/main/resources/reference.conf 
b/persistence/src/main/resources/reference.conf
index ff34a035bb..c00b1f5763 100644
--- a/persistence/src/main/resources/reference.conf
+++ b/persistence/src/main/resources/reference.conf
@@ -115,6 +115,11 @@ pekko.persistence {
       # as it has accumulated since the last write.
       max-message-batch-size = 200
 
+      # Maximum number of events that an AsyncWriteJournal replays before 
waiting for
+      # the recovering persistent actor to acknowledge that it has processed 
the batch.
+      # This bounds the number of replayed events queued in the persistent 
actor's mailbox.
+      replay-batch-size = 1000
+
       # If there is more time in between individual events gotten from the 
journal
       # recovery than this the recovery will fail.
       # Note that it also affects reading the snapshot before replaying events 
on
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala 
b/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
index cb2006526c..e0593256ab 100644
--- a/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
+++ b/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
@@ -253,7 +253,12 @@ private[persistence] trait Eventsourced
 
   /** INTERNAL API. */
   override protected[pekko] def aroundReceive(receive: Receive, message: Any): 
Unit =
-    currentState.stateReceive(receive, message)
+    message match {
+      case ReplayBatchResponse(`instanceId`, response) if 
currentState.recoveryRunning =>
+        currentState.stateReceive(receive, response)
+      case _: ReplayBatchResponse => // stale or late replay response
+      case other                  => currentState.stateReceive(receive, other)
+    }
 
   /** INTERNAL API. */
   override protected[pekko] def aroundPreStart(): Unit = {
@@ -684,6 +689,7 @@ private[persistence] trait Eventsourced
             }
         }
         changeState(recovering(recoveryBehavior, timeout))
+        journal ! ReplayMessagesWithBatching(instanceId)
         journal ! ReplayMessages(lastSequenceNr + 1L, toSnr, replayMax, 
persistenceId, self)
       }
 
@@ -762,7 +768,7 @@ private[persistence] trait Eventsourced
 
       override def recoveryRunning: Boolean = _recoveryRunning
 
-      override def stateReceive(receive: Receive, message: Any) =
+      override def stateReceive(receive: Receive, message: Any): Unit =
         try message match {
             case ReplayedMessage(p) =>
               try {
@@ -776,6 +782,9 @@ private[persistence] trait Eventsourced
                   finally context.stop(self)
                   returnRecoveryPermit()
               }
+            case ReplayBatchReady(replayId) =>
+              eventSeenInInterval = true
+              journal ! ReplayBatchAck(replayId)
             case RecoverySuccess(highestJournalSeqNr) =>
               timeoutCancellable.cancel()
               onReplaySuccess() // callback for subclass implementation
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/JournalProtocol.scala 
b/persistence/src/main/scala/org/apache/pekko/persistence/JournalProtocol.scala
index d82b427906..c23ecfc469 100644
--- 
a/persistence/src/main/scala/org/apache/pekko/persistence/JournalProtocol.scala
+++ 
b/persistence/src/main/scala/org/apache/pekko/persistence/JournalProtocol.scala
@@ -125,6 +125,26 @@ private[persistence] object JournalProtocol {
       persistentActor: ActorRef)
       extends Request
 
+  /**
+   * Opts the sender in to bounded replay for the immediately following 
[[ReplayMessages]] request.
+   * Journals that don't support bounded replay can ignore this message and 
process [[ReplayMessages]] as before.
+   * The `actorInstanceId` identifies the current incarnation of the 
recovering actor so that responses from a
+   * cancelled replay can be discarded after a restart.
+   */
+  final case class ReplayMessagesWithBatching(actorInstanceId: Int) extends 
Request
+
+  /** Cancels all bounded replays requested by the sender. */
+  case object ReplayMessagesCancel extends Request
+
+  /**
+   * Acknowledges that the requester has processed the replayed messages 
emitted before the corresponding
+   * [[ReplayBatchReady]].
+   */
+  final case class ReplayBatchAck(replayId: Long) extends Request
+
+  /** Cancels a bounded replay after its replay filter has failed. */
+  final case class ReplayBatchCancel(replayId: Long) extends Request
+
   /**
    * Reply message to a [[ReplayMessages]] request. A separate reply is sent 
to the requester for each
    * replayed message.
@@ -136,6 +156,21 @@ private[persistence] object JournalProtocol {
       with DeadLetterSuppression
       with NoSerializationVerificationNeeded
 
+  /**
+   * Signals that one bounded replay batch has been emitted. The requester 
must process all preceding
+   * [[ReplayedMessage]] responses and reply with [[ReplayBatchAck]] before 
the journal emits another batch.
+   */
+  final case class ReplayBatchReady(replayId: Long)
+      extends Response
+      with DeadLetterSuppression
+      with NoSerializationVerificationNeeded
+
+  /** Associates a bounded replay response with the recovering actor 
incarnation that requested it. */
+  final case class ReplayBatchResponse(actorInstanceId: Int, response: 
Response)
+      extends Response
+      with DeadLetterSuppression
+      with NoSerializationVerificationNeeded
+
   /**
    * Reply message to a successful [[ReplayMessages]] request. This reply is 
sent to the requester
    * after all [[ReplayedMessage]] have been sent (if any).
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/AsyncWriteJournal.scala
 
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/AsyncWriteJournal.scala
index ae27bd5c4f..6afe37527e 100644
--- 
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/AsyncWriteJournal.scala
+++ 
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/AsyncWriteJournal.scala
@@ -13,7 +13,9 @@
 
 package org.apache.pekko.persistence.journal
 
-import scala.collection.immutable
+import java.util.concurrent.atomic.AtomicLong
+
+import scala.collection.{ immutable, mutable }
 import scala.concurrent.ExecutionContext
 import scala.concurrent.Future
 import scala.concurrent.duration._
@@ -69,10 +71,138 @@ trait AsyncWriteJournal extends Actor with 
WriteJournalBase with AsyncRecovery {
     // cannot be a val in the trait due to binary compatibility
     val replayDebugEnabled: Boolean = config.getBoolean("replay-filter.debug")
     val enableGlobalWriteResponseOrder: Boolean = 
config.getBoolean("write-response-global-order")
+    val replayBatchSize: Long = config.getLong("replay-batch-size")
+    require(replayBatchSize > 0L, s"replay-batch-size must be greater than 0, 
but was [$replayBatchSize]")
 
     val eventStream = context.system.eventStream // used from Future callbacks
     implicit val ec: ExecutionContext = context.dispatcher
 
+    val batchingReplayRequesters = mutable.Map.empty[ActorRef, Int]
+    val replaySessions = mutable.Map.empty[Long, ReplaySession]
+
+    def replayTarget(persistentActor: ActorRef): ActorRef =
+      if (isReplayFilterEnabled)
+        context.actorOf(
+          ReplayFilter.props(
+            persistentActor,
+            replayFilterMode,
+            replayFilterWindowSize,
+            replayFilterMaxOldWriters,
+            replayDebugEnabled))
+      else persistentActor
+
+    def batchedReplayTarget(persistentActor: ActorRef, actorInstanceId: Int): 
(ActorRef, ActorRef) = {
+      val relay = context.actorOf(replayForwarderProps(persistentActor, 
actorInstanceId))
+      val replyTo =
+        if (isReplayFilterEnabled)
+          context.actorOf(
+            ReplayFilter.props(
+              relay,
+              replayFilterMode,
+              replayFilterWindowSize,
+              replayFilterMaxOldWriters,
+              replayDebugEnabled))
+        else relay
+      (replyTo, relay)
+    }
+
+    def hasReplaySession(persistentActor: ActorRef): Boolean =
+      replaySessions.valuesIterator.exists(_.request.persistentActor == 
persistentActor)
+
+    def unwatchIfUnused(persistentActor: ActorRef): Unit =
+      if (!batchingReplayRequesters.contains(persistentActor) && 
!hasReplaySession(persistentActor))
+        context.unwatch(persistentActor)
+
+    def removeReplaySession(replayId: Long): Option[ReplaySession] =
+      replaySessions.remove(replayId).map { session =>
+        val persistentActor = session.request.persistentActor
+        unwatchIfUnused(persistentActor)
+        session
+      }
+
+    def publishReplayRequest(session: ReplaySession): Unit =
+      if (publish) eventStream.publish(session.request)
+
+    def finishReplay(replayId: Long, response: JournalProtocol.Response): Unit 
=
+      removeReplaySession(replayId).foreach { session =>
+        session.replyTo.tell(response, self)
+        publishReplayRequest(session)
+      }
+
+    def cancelReplay(replayId: Long): Unit =
+      removeReplaySession(replayId).foreach { session =>
+        if (session.replyTo != session.relay)
+          context.stop(session.replyTo)
+        context.stop(session.relay)
+        publishReplayRequest(session)
+      }
+
+    def cancelReplaysFor(persistentActor: ActorRef): Unit =
+      replaySessions.valuesIterator
+        .filter(_.request.persistentActor == persistentActor)
+        .map(_.replayId)
+        .toVector
+        .foreach(cancelReplay)
+
+    def batchUpperBound(fromSequenceNr: Long, toSequenceNr: Long): Long = {
+      val increment = replayBatchSize - 1L
+      val upperBound =
+        if (fromSequenceNr > Long.MaxValue - increment) Long.MaxValue
+        else fromSequenceNr + increment
+      math.min(toSequenceNr, upperBound)
+    }
+
+    def startReplayBatch(session: ReplaySession): Unit = {
+      val batchToSequenceNr = batchUpperBound(session.nextSequenceNr, 
session.toSequenceNr)
+      val batchMax = math.min(replayBatchSize, session.remaining)
+      val replayed = new AtomicLong
+      val replayResult =
+        try
+          asyncReplayMessages(
+            session.request.persistenceId,
+            session.nextSequenceNr,
+            batchToSequenceNr,
+            batchMax) { p =>
+            replayed.incrementAndGet()
+            if (!p.deleted) // old records from Akka 2.3 may still have the 
deleted flag
+              adaptFromJournal(p).foreach { adaptedPersistentRepr =>
+                session.replyTo.tell(ReplayedMessage(adaptedPersistentRepr), 
self)
+              }
+          }
+        catch { case NonFatal(e) => Future.failed(e) }
+
+      replayResult
+        .map(_ => ReplayBatchCompleted(session.replayId, batchToSequenceNr, 
replayed.get()))
+        .recover { case e => ReplayFailed(session.replayId, e) }
+        .pipeTo(self)
+    }
+
+    def startBatchedReplay(request: ReplayMessages, actorInstanceId: Int): 
Unit = {
+      val replayId = nextReplayId()
+      val (replyTo, relay) = batchedReplayTarget(request.persistentActor, 
actorInstanceId)
+      val session = ReplaySession(
+        replayId,
+        request,
+        replyTo,
+        relay,
+        highestSequenceNr = 0L,
+        toSequenceNr = 0L,
+        nextSequenceNr = request.fromSequenceNr,
+        remaining = request.max,
+        awaitingAck = false)
+      replaySessions.put(replayId, session)
+      context.watch(request.persistentActor)
+
+      val readHighestSequenceNrFrom = math.max(0L, request.fromSequenceNr - 1L)
+      val readHighestResult =
+        try 
breaker.withCircuitBreaker(asyncReadHighestSequenceNr(request.persistenceId, 
readHighestSequenceNrFrom))
+        catch { case NonFatal(e) => Future.failed(e) }
+      readHighestResult
+        .map(ReplayHighestSequenceNr(replayId, _))
+        .recover { case e => ReplayFailed(replayId, e) }
+        .pipeTo(self)
+    }
+
     // should be a private method in the trait, but it needs the 
enableGlobalWriteResponseOrder field which can't be
     // moved to the trait level because adding any fields there breaks 
bincompat
     @InternalApi
@@ -159,52 +289,113 @@ trait AsyncWriteJournal extends Actor with 
WriteJournalBase with AsyncRecovery {
             }
         }
 
+      case ReplayMessagesWithBatching(actorInstanceId) =>
+        val requester = sender()
+        cancelReplaysFor(requester)
+        batchingReplayRequesters.put(requester, actorInstanceId)
+        context.watch(requester)
+
+      case ReplayMessagesCancel =>
+        val requester = sender()
+        batchingReplayRequesters.remove(requester)
+        cancelReplaysFor(requester)
+        unwatchIfUnused(requester)
+
       case r @ ReplayMessages(fromSequenceNr, toSequenceNr, max, 
persistenceId, persistentActor) =>
-        val replyTo =
-          if (isReplayFilterEnabled)
-            context.actorOf(
-              ReplayFilter.props(
-                persistentActor,
-                replayFilterMode,
-                replayFilterWindowSize,
-                replayFilterMaxOldWriters,
-                replayDebugEnabled))
-          else persistentActor
-
-        val readHighestSequenceNrFrom = math.max(0L, fromSequenceNr - 1)
-        /*
-         * The API docs for the [[AsyncRecovery]] say not to rely on 
asyncReadHighestSequenceNr
-         * being called before a call to asyncReplayMessages even tho it 
currently always is. The Cassandra
-         * plugin does rely on this so if you change this change the Cassandra 
plugin.
-         */
-        breaker
-          .withCircuitBreaker(asyncReadHighestSequenceNr(persistenceId, 
readHighestSequenceNrFrom))
-          .flatMap { highSeqNr =>
-            val toSeqNr = math.min(toSequenceNr, highSeqNr)
-            if (toSeqNr <= 0L || fromSequenceNr > toSeqNr)
-              Future.successful(highSeqNr)
-            else {
-              // Send replayed messages and replay result to persistentActor 
directly. No need
-              // to resequence replayed messages relative to written and 
looped messages.
-              // not possible to use circuit breaker here
-              asyncReplayMessages(persistenceId, fromSequenceNr, toSeqNr, max) 
{ p =>
-                if (!p.deleted) // old records from Akka 2.3 may still have 
the deleted flag
-                  adaptFromJournal(p).foreach { adaptedPersistentRepr =>
-                    replyTo.tell(ReplayedMessage(adaptedPersistentRepr), 
Actor.noSender)
-                  }
-              }.map(_ => highSeqNr)
+        val requester = sender()
+        if (requester == persistentActor)
+          cancelReplaysFor(persistentActor)
+        val requestedBatching = batchingReplayRequesters.remove(requester)
+        if (requestedBatching.isDefined && requester == persistentActor) {
+          startBatchedReplay(r, requestedBatching.get)
+        } else {
+          if (requestedBatching.isDefined)
+            unwatchIfUnused(requester)
+          val replyTo = replayTarget(persistentActor)
+
+          val readHighestSequenceNrFrom = math.max(0L, fromSequenceNr - 1)
+          /*
+           * The API docs for the [[AsyncRecovery]] say not to rely on 
asyncReadHighestSequenceNr
+           * being called before a call to asyncReplayMessages even tho it 
currently always is. The Cassandra
+           * plugin does rely on this so if you change this change the 
Cassandra plugin.
+           */
+          breaker
+            .withCircuitBreaker(asyncReadHighestSequenceNr(persistenceId, 
readHighestSequenceNrFrom))
+            .flatMap { highSeqNr =>
+              val toSeqNr = math.min(toSequenceNr, highSeqNr)
+              if (toSeqNr <= 0L || fromSequenceNr > toSeqNr)
+                Future.successful(highSeqNr)
+              else {
+                // Send replayed messages and replay result to persistentActor 
directly. No need
+                // to resequence replayed messages relative to written and 
looped messages.
+                // not possible to use circuit breaker here
+                asyncReplayMessages(persistenceId, fromSequenceNr, toSeqNr, 
max) { p =>
+                  if (!p.deleted) // old records from Akka 2.3 may still have 
the deleted flag
+                    adaptFromJournal(p).foreach { adaptedPersistentRepr =>
+                      replyTo.tell(ReplayedMessage(adaptedPersistentRepr), 
Actor.noSender)
+                    }
+                }.map(_ => highSeqNr)
+              }
             }
+            .map { highSeqNr =>
+              RecoverySuccess(highSeqNr)
+            }
+            .recover {
+              case e => ReplayMessagesFailure(e)
+            }
+            .pipeTo(replyTo)
+            .foreach { _ =>
+              if (publish) eventStream.publish(r)
+            }
+        }
+
+      case ReplayHighestSequenceNr(replayId, highSeqNr) =>
+        replaySessions.get(replayId).foreach { session =>
+          val toSeqNr = math.min(session.request.toSequenceNr, highSeqNr)
+          val initialized = session.copy(highestSequenceNr = highSeqNr, 
toSequenceNr = toSeqNr)
+          replaySessions.update(replayId, initialized)
+          if (toSeqNr <= 0L || session.nextSequenceNr > toSeqNr || 
session.remaining <= 0L)
+            finishReplay(replayId, RecoverySuccess(highSeqNr))
+          else
+            startReplayBatch(initialized)
+        }
+
+      case ReplayBatchCompleted(replayId, batchToSequenceNr, replayed) =>
+        replaySessions.get(replayId).foreach { session =>
+          val remaining = if (replayed >= session.remaining) 0L else 
session.remaining - replayed
+          if (remaining == 0L || batchToSequenceNr >= session.toSequenceNr) {
+            finishReplay(replayId, RecoverySuccess(session.highestSequenceNr))
+          } else {
+            val waiting = session.copy(
+              nextSequenceNr = batchToSequenceNr + 1L,
+              remaining = remaining,
+              awaitingAck = true)
+            replaySessions.update(replayId, waiting)
+            session.replyTo.tell(ReplayBatchReady(replayId), self)
           }
-          .map { highSeqNr =>
-            RecoverySuccess(highSeqNr)
-          }
-          .recover {
-            case e => ReplayMessagesFailure(e)
-          }
-          .pipeTo(replyTo)
-          .foreach { _ =>
-            if (publish) eventStream.publish(r)
-          }
+        }
+
+      case ReplayBatchAck(replayId) =>
+        replaySessions.get(replayId) match {
+          case Some(session) if session.awaitingAck && sender() == 
session.request.persistentActor =>
+            val replaying = session.copy(awaitingAck = false)
+            replaySessions.update(replayId, replaying)
+            startReplayBatch(replaying)
+          case _ => // stale or invalid acknowledgement
+        }
+
+      case ReplayBatchCancel(replayId) =>
+        replaySessions.get(replayId) match {
+          case Some(session) if sender() == session.replyTo => 
cancelReplay(replayId)
+          case _                                            => // stale or 
invalid cancellation
+        }
+
+      case ReplayFailed(replayId, cause) =>
+        finishReplay(replayId, ReplayMessagesFailure(cause))
+
+      case Terminated(persistentActor) =>
+        batchingReplayRequesters.remove(persistentActor)
+        cancelReplaysFor(persistentActor)
 
       case d @ DeleteMessagesTo(persistenceId, toSequenceNr, persistentActor) 
=>
         breaker
@@ -317,8 +508,45 @@ trait AsyncWriteJournal extends Actor with 
WriteJournalBase with AsyncRecovery {
  * INTERNAL API.
  */
 private[persistence] object AsyncWriteJournal {
+  import JournalProtocol._
+
   val successUnit: Success[Unit] = Success(())
 
+  private val replayIdCounter = new AtomicLong
+
+  private def nextReplayId(): Long = replayIdCounter.incrementAndGet()
+
+  private final case class ReplaySession(
+      replayId: Long,
+      request: ReplayMessages,
+      replyTo: ActorRef,
+      relay: ActorRef,
+      highestSequenceNr: Long,
+      toSequenceNr: Long,
+      nextSequenceNr: Long,
+      remaining: Long,
+      awaitingAck: Boolean)
+
+  private final case class ReplayHighestSequenceNr(replayId: Long, 
highestSequenceNr: Long)
+      extends NoSerializationVerificationNeeded
+  private final case class ReplayBatchCompleted(replayId: Long, toSequenceNr: 
Long, replayed: Long)
+      extends NoSerializationVerificationNeeded
+  private final case class ReplayFailed(replayId: Long, cause: Throwable) 
extends NoSerializationVerificationNeeded
+
+  private def replayForwarderProps(persistentActor: ActorRef, actorInstanceId: 
Int): Props =
+    Props(new ReplayForwarder(persistentActor, actorInstanceId))
+
+  private final class ReplayForwarder(persistentActor: ActorRef, 
actorInstanceId: Int) extends Actor {
+    def receive = {
+      case response: JournalProtocol.Response =>
+        persistentActor.tell(ReplayBatchResponse(actorInstanceId, response), 
Actor.noSender)
+        response match {
+          case _: RecoverySuccess | _: ReplayMessagesFailure => 
context.stop(self)
+          case _                                             =>
+        }
+    }
+  }
+
   final case class Desequenced(msg: Any, snr: Long, target: ActorRef, sender: 
ActorRef)
       extends NoSerializationVerificationNeeded
 
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/PersistencePluginProxy.scala
 
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/PersistencePluginProxy.scala
index c06b4b376d..ba02fb9ad4 100644
--- 
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/PersistencePluginProxy.scala
+++ 
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/PersistencePluginProxy.scala
@@ -212,7 +212,9 @@ final class PersistencePluginProxy(config: Config) extends 
Actor with Stash with
           }
         case ReplayMessages(_, _, _, _, persistentActor) =>
           persistentActor ! ReplayMessagesFailure(timeoutException())
-        case DeleteMessagesTo(_, toSequenceNr, persistentActor) =>
+        // The following ReplayMessages request has already failed, so control 
messages don't need a response.
+        case ReplayMessagesWithBatching(_) | ReplayMessagesCancel | 
ReplayBatchAck(_) | ReplayBatchCancel(_) => ()
+        case DeleteMessagesTo(_, toSequenceNr, persistentActor)                
                              =>
           persistentActor ! DeleteMessagesFailure(timeoutException(), 
toSequenceNr)
       }
 
diff --git 
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/ReplayFilter.scala
 
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/ReplayFilter.scala
index 02943b7fa6..462908ad73 100644
--- 
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/ReplayFilter.scala
+++ 
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/ReplayFilter.scala
@@ -159,6 +159,11 @@ private[pekko] class ReplayFilter(
         case e: IllegalStateException if mode == Fail => fail(e)
       }
 
+    case msg: ReplayBatchReady =>
+      if (debugEnabled)
+        log.debug("Replay batch completed: {}", msg)
+      persistentActor.tell(msg, Actor.noSender)
+
     case msg @ (_: RecoverySuccess | _: ReplayMessagesFailure) =>
       if (debugEnabled)
         log.debug("Replay completed: {}", msg)
@@ -183,7 +188,10 @@ private[pekko] class ReplayFilter(
     buffer.clear()
     persistentActor.tell(ReplayMessagesFailure(cause), Actor.noSender)
     context.become {
-      case _: ReplayedMessage                            => // discard
+      case _: ReplayedMessage    => // discard
+      case msg: ReplayBatchReady =>
+        sender() ! ReplayBatchCancel(msg.replayId)
+        context.stop(self)
       case _: RecoverySuccess | _: ReplayMessagesFailure =>
         context.stop(self)
     }
diff --git 
a/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorJournalProtocolSpec.scala
 
b/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorJournalProtocolSpec.scala
index 711037f6a5..1ae2bdca46 100644
--- 
a/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorJournalProtocolSpec.scala
+++ 
b/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorJournalProtocolSpec.scala
@@ -144,11 +144,16 @@ class PersistentActorJournalProtocolSpec extends 
PekkoSpec(config) with Implicit
     }
   }
 
+  def expectReplayMessages(): ReplayMessages = {
+    journal.expectMsgType[ReplayMessagesWithBatching]
+    journal.expectMsgType[ReplayMessages]
+  }
+
   def startActor(name: String): ActorRef = {
     val subject = system.actorOf(Props(new A(testActor)), name)
     subject ! Echo(0)
     expectMsg(PreStart(name))
-    journal.expectMsgType[ReplayMessages]
+    expectReplayMessages()
     journal.reply(RecoverySuccess(0L))
     expectMsg(RecoveryCompleted)
     expectMsg(Done(0, 0))
@@ -256,7 +261,7 @@ class PersistentActorJournalProtocolSpec extends 
PekkoSpec(config) with Implicit
           subject ! Fail(new Exception("K-BOOM!"))
           expectMsg(PreRestart("test-6"))
           expectMsg(PostRestart("test-6"))
-          journal.expectMsgType[ReplayMessages]
+          expectReplayMessages()
         }
         journal.reply(RecoverySuccess(0L))
         expectMsg(RecoveryCompleted)
diff --git 
a/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorReplayBatchingSpec.scala
 
b/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorReplayBatchingSpec.scala
new file mode 100644
index 0000000000..e0161d9100
--- /dev/null
+++ 
b/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorReplayBatchingSpec.scala
@@ -0,0 +1,387 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.pekko.persistence
+
+import scala.collection.immutable
+import scala.concurrent.{ Await, Future, Promise }
+import scala.concurrent.duration._
+import scala.util.Try
+
+import org.apache.pekko.actor.{ ActorIdentity, ActorRef, Identify, Props }
+import org.apache.pekko.persistence.journal.{ AsyncWriteJournal, 
SteppingInmemJournal }
+import org.apache.pekko.testkit.{ PekkoSpec, TestProbe }
+
+import com.typesafe.config.ConfigFactory
+
+object RestartingReplayJournal {
+  case object Crash
+  final class CrashException extends RuntimeException("restart the test 
journal")
+
+  final case class Control(incarnations: Vector[Future[Unit]], reads: 
Vector[Future[Unit]])
+
+  private var incarnationStarted = Vector.empty[Promise[Unit]]
+  private var readStarted = Vector.empty[Promise[Unit]]
+  private var readResults = Vector.empty[Promise[Long]]
+  private var incarnation = 0
+  private var read = 0
+
+  def prepare(): Control = synchronized {
+    incarnationStarted = Vector.fill(2)(Promise[Unit]())
+    readStarted = Vector.fill(2)(Promise[Unit]())
+    readResults = Vector.fill(2)(Promise[Long]())
+    incarnation = 0
+    read = 0
+    Control(incarnationStarted.map(_.future), readStarted.map(_.future))
+  }
+
+  private def onStart(): Unit = synchronized {
+    incarnationStarted(incarnation).success(())
+    incarnation += 1
+  }
+
+  private def nextRead(): Future[Long] = synchronized {
+    val current = read
+    read += 1
+    readStarted(current).success(())
+    readResults(current).future
+  }
+
+  def completeRead(read: Int, highestSequenceNr: Long): Unit = synchronized {
+    readResults(read).success(highestSequenceNr)
+  }
+}
+
+final class RestartingReplayJournal extends AsyncWriteJournal {
+  import RestartingReplayJournal._
+
+  override def preStart(): Unit = {
+    super.preStart()
+    onStart()
+  }
+
+  override def receivePluginInternal: Receive = {
+    case Crash => throw new CrashException
+  }
+
+  override def asyncWriteMessages(
+      messages: immutable.Seq[AtomicWrite]): Future[immutable.Seq[Try[Unit]]] 
= Future.successful(Nil)
+
+  override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: 
Long): Future[Unit] =
+    Future.successful(())
+
+  override def asyncReadHighestSequenceNr(persistenceId: String, 
fromSequenceNr: Long): Future[Long] =
+    nextRead()
+
+  override def asyncReplayMessages(persistenceId: String, fromSequenceNr: 
Long, toSequenceNr: Long, max: Long)(
+      recoveryCallback: PersistentRepr => Unit): Future[Unit] = 
Future.successful(())
+}
+
+object PersistentActorReplayBatchingSpec {
+  val JournalId = "persistent-actor-replay-batching-spec"
+  val RestartingJournalId = "restarting-replay-journal"
+
+  final case class PersistAll(events: Vector[String])
+  final case class Persisted(event: String)
+  final case class DeleteTo(toSequenceNr: Long)
+  final case class Deleted(toSequenceNr: Long)
+  final case class Replayed(event: String, eventSender: ActorRef)
+  case object RecoveryFinished
+
+  val config =
+    SteppingInmemJournal
+      .config(JournalId)
+      .withFallback(ConfigFactory.parseString(s"""
+          pekko.persistence.journal.stepping-inmem {
+            replay-batch-size = 2
+            replay-filter.mode = off
+          }
+          $RestartingJournalId = $${pekko.persistence.journal-plugin-fallback}
+          $RestartingJournalId {
+            class = "org.apache.pekko.persistence.RestartingReplayJournal"
+            replay-batch-size = 2
+            replay-filter.mode = off
+          }
+        """))
+      .withFallback(PersistenceSpec.config("stepping-inmem", 
"PersistentActorReplayBatchingSpec"))
+      .withFallback(ConfigFactory.defaultReference())
+      .resolve()
+
+  final class TestActor(override val persistenceId: String, replayMax: Long, 
probe: ActorRef) extends PersistentActor {
+    override def recovery: Recovery = Recovery(replayMax = replayMax)
+
+    override def receiveRecover: Receive = {
+      case event: String     => probe ! Replayed(event, sender())
+      case RecoveryCompleted => probe ! RecoveryFinished
+    }
+
+    override def receiveCommand: Receive = {
+      case PersistAll(events) =>
+        val replyTo = sender()
+        persistAll(events) { event =>
+          replyTo ! Persisted(event)
+        }
+      case DeleteTo(toSequenceNr)              => deleteMessages(toSequenceNr)
+      case DeleteMessagesSuccess(toSequenceNr) => probe ! Deleted(toSequenceNr)
+    }
+  }
+}
+
+class PersistentActorReplayBatchingSpec extends 
PekkoSpec(PersistentActorReplayBatchingSpec.config) {
+  import PersistentActorReplayBatchingSpec._
+  import JournalProtocol._
+
+  private def nextBatchedResponse(receiver: TestProbe, actorInstanceId: Int): 
JournalProtocol.Response = {
+    val wrapped = receiver.expectMsgType[ReplayBatchResponse]
+    wrapped.actorInstanceId shouldBe actorInstanceId
+    receiver.lastSender shouldBe system.deadLetters
+    wrapped.response
+  }
+
+  private def expectReplayed(receiver: TestProbe, actorInstanceId: Int, 
payload: String): Unit =
+    nextBatchedResponse(receiver, actorInstanceId) match {
+      case ReplayedMessage(persistent) => persistent.payload shouldBe payload
+      case other                       => fail(s"Expected ReplayedMessage, got 
[$other]")
+    }
+
+  private def expectBatchReady(receiver: TestProbe, actorInstanceId: Int): 
ReplayBatchReady =
+    nextBatchedResponse(receiver, actorInstanceId) match {
+      case ready: ReplayBatchReady => ready
+      case other                   => fail(s"Expected ReplayBatchReady, got 
[$other]")
+    }
+
+  "A PersistentActor recovery" should {
+    "acknowledge each bounded replay batch before the journal produces the 
next batch" in {
+      val probe = TestProbe()
+      val persistenceId = "bounded-replay"
+      val events = Vector("a", "b", "c", "d", "e")
+
+      val persisting = system.actorOf(Props(classOf[TestActor], persistenceId, 
Long.MaxValue, probe.ref))
+      awaitAssert(SteppingInmemJournal.getRef(JournalId), 3.seconds)
+      val journal = SteppingInmemJournal.getRef(JournalId)
+
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(RecoveryFinished)
+
+      persisting.tell(PersistAll(events), probe.ref)
+      SteppingInmemJournal.step(journal)
+      events.foreach(event => probe.expectMsg(Persisted(event)))
+
+      watch(persisting)
+      system.stop(persisting)
+      expectTerminated(persisting)
+
+      system.actorOf(Props(classOf[TestActor], persistenceId, Long.MaxValue, 
probe.ref))
+      SteppingInmemJournal.step(journal) // read highest sequence number
+
+      SteppingInmemJournal.step(journal) // replay 1-2
+      probe.expectMsg(Replayed("a", system.deadLetters))
+      probe.expectMsg(Replayed("b", system.deadLetters))
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay 3-4
+      probe.expectMsg(Replayed("c", system.deadLetters))
+      probe.expectMsg(Replayed("d", system.deadLetters))
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay 5 and complete
+      probe.expectMsg(Replayed("e", system.deadLetters))
+      probe.expectMsg(RecoveryFinished)
+    }
+
+    "preserve the total replayMax limit across batches" in {
+      val probe = TestProbe()
+      val persistenceId = "bounded-replay-max"
+      val events = Vector("a", "b", "c", "d", "e")
+
+      val persisting = system.actorOf(Props(classOf[TestActor], persistenceId, 
Long.MaxValue, probe.ref))
+      val journal = SteppingInmemJournal.getRef(JournalId)
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(RecoveryFinished)
+
+      persisting.tell(PersistAll(events), probe.ref)
+      SteppingInmemJournal.step(journal)
+      events.foreach(event => probe.expectMsg(Persisted(event)))
+      watch(persisting)
+      system.stop(persisting)
+      expectTerminated(persisting)
+
+      system.actorOf(Props(classOf[TestActor], persistenceId, 3L, probe.ref))
+      SteppingInmemJournal.step(journal) // read highest sequence number
+
+      SteppingInmemJournal.step(journal) // replay 1-2
+      probe.expectMsg(Replayed("a", system.deadLetters))
+      probe.expectMsg(Replayed("b", system.deadLetters))
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay only 3 because replayMax is 
now exhausted
+      probe.expectMsg(Replayed("c", system.deadLetters))
+      probe.expectMsg(RecoveryFinished)
+      probe.expectNoMessage(100.millis)
+    }
+
+    "advance across empty sequence number ranges caused by deleted events" in {
+      val probe = TestProbe()
+      val persistenceId = "bounded-replay-deleted-events"
+      val events = Vector("a", "b", "c", "d", "e")
+
+      val persisting = system.actorOf(Props(classOf[TestActor], persistenceId, 
Long.MaxValue, probe.ref))
+      val journal = SteppingInmemJournal.getRef(JournalId)
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(RecoveryFinished)
+
+      persisting.tell(PersistAll(events), probe.ref)
+      SteppingInmemJournal.step(journal)
+      events.foreach(event => probe.expectMsg(Persisted(event)))
+
+      persisting ! DeleteTo(3L)
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(Deleted(3L))
+      watch(persisting)
+      system.stop(persisting)
+      expectTerminated(persisting)
+
+      system.actorOf(Props(classOf[TestActor], persistenceId, Long.MaxValue, 
probe.ref))
+      SteppingInmemJournal.step(journal) // read highest sequence number
+
+      SteppingInmemJournal.step(journal) // replay empty range 1-2
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay 3-4, with only 4 remaining
+      probe.expectMsg(Replayed("d", system.deadLetters))
+      probe.expectNoMessage(100.millis)
+
+      SteppingInmemJournal.step(journal) // replay 5 and complete
+      probe.expectMsg(Replayed("e", system.deadLetters))
+      probe.expectMsg(RecoveryFinished)
+    }
+
+    "prevent the journal from producing another batch before acknowledgement" 
in {
+      val probe = TestProbe()
+      val persistenceId = "withheld-batch-ack"
+      val events = Vector("a", "b", "c", "d", "e")
+
+      val persisting = system.actorOf(Props(classOf[TestActor], persistenceId, 
Long.MaxValue, probe.ref))
+      val journal = SteppingInmemJournal.getRef(JournalId)
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(RecoveryFinished)
+      persisting.tell(PersistAll(events), probe.ref)
+      SteppingInmemJournal.step(journal)
+      events.foreach(event => probe.expectMsg(Persisted(event)))
+      watch(persisting)
+      system.stop(persisting)
+      expectTerminated(persisting)
+
+      val receiver = TestProbe()
+      val actorInstanceId = 17
+      journal.tell(ReplayMessagesWithBatching(actorInstanceId), receiver.ref)
+      journal.tell(ReplayMessages(1L, Long.MaxValue, Long.MaxValue, 
persistenceId, receiver.ref), receiver.ref)
+      SteppingInmemJournal.step(journal) // read highest sequence number
+      SteppingInmemJournal.step(journal) // replay 1-2
+
+      expectReplayed(receiver, actorInstanceId, "a")
+      expectReplayed(receiver, actorInstanceId, "b")
+      val firstBatch = expectBatchReady(receiver, actorInstanceId)
+
+      // Make a replay token available, but withhold the acknowledgement. No 
second replay call may start.
+      val tokenProbe = TestProbe()
+      journal.tell(SteppingInmemJournal.Token, tokenProbe.ref)
+      receiver.expectNoMessage(100.millis)
+      tokenProbe.expectNoMessage(100.millis)
+
+      journal.tell(ReplayBatchAck(firstBatch.replayId), receiver.ref)
+      tokenProbe.expectMsg(SteppingInmemJournal.TokenConsumed)
+      expectReplayed(receiver, actorInstanceId, "c")
+      expectReplayed(receiver, actorInstanceId, "d")
+      val secondBatch = expectBatchReady(receiver, actorInstanceId)
+
+      journal.tell(ReplayBatchAck(secondBatch.replayId), receiver.ref)
+      SteppingInmemJournal.step(journal)
+      expectReplayed(receiver, actorInstanceId, "e")
+      nextBatchedResponse(receiver, actorInstanceId) shouldBe 
RecoverySuccess(5L)
+    }
+
+    "discard an unpaired batching request when its requester terminates" in {
+      val probe = TestProbe()
+      val persistenceId = "terminated-batching-requester"
+      val journal = SteppingInmemJournal.getRef(JournalId)
+
+      val persisting = system.actorOf(Props(classOf[TestActor], persistenceId, 
Long.MaxValue, probe.ref))
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(RecoveryFinished)
+      persisting.tell(PersistAll(Vector("a")), probe.ref)
+      SteppingInmemJournal.step(journal)
+      probe.expectMsg(Persisted("a"))
+      watch(persisting)
+      system.stop(persisting)
+      expectTerminated(persisting)
+
+      val shortLivedRequester = TestProbe()
+      journal.tell(ReplayMessagesWithBatching(actorInstanceId = 23), 
shortLivedRequester.ref)
+      journal.tell(Identify("batching-request-registered"), testActor)
+      expectMsg(ActorIdentity("batching-request-registered", Some(journal)))
+      watch(shortLivedRequester.ref)
+      system.stop(shortLivedRequester.ref)
+      expectTerminated(shortLivedRequester.ref)
+      journal.tell(Identify("batching-request-removed"), testActor)
+      expectMsg(ActorIdentity("batching-request-removed", Some(journal)))
+
+      val published = TestProbe()
+      system.eventStream.subscribe(published.ref, classOf[ReplayMessages])
+      journal.tell(
+        ReplayMessages(1L, Long.MaxValue, Long.MaxValue, persistenceId, 
shortLivedRequester.ref),
+        shortLivedRequester.ref)
+      published.expectNoMessage(100.millis)
+      SteppingInmemJournal.step(journal)
+      SteppingInmemJournal.step(journal)
+      published.expectMsgType[ReplayMessages].persistenceId shouldBe 
persistenceId
+      system.eventStream.unsubscribe(published.ref)
+    }
+
+    "ignore a replay completion from an earlier journal actor incarnation" in {
+      val control = RestartingReplayJournal.prepare()
+      val journal = Persistence(system).journalFor(RestartingJournalId)
+      Await.result(control.incarnations.head, 3.seconds)
+
+      val firstRequester = TestProbe()
+      journal.tell(ReplayMessagesWithBatching(actorInstanceId = 31), 
firstRequester.ref)
+      journal.tell(
+        ReplayMessages(1L, Long.MaxValue, Long.MaxValue, 
"old-journal-incarnation", firstRequester.ref),
+        firstRequester.ref)
+      Await.result(control.reads.head, 3.seconds)
+
+      journal ! RestartingReplayJournal.Crash
+      Await.result(control.incarnations(1), 3.seconds)
+
+      val secondRequester = TestProbe()
+      journal.tell(ReplayMessagesWithBatching(actorInstanceId = 32), 
secondRequester.ref)
+      journal.tell(
+        ReplayMessages(1L, Long.MaxValue, Long.MaxValue, 
"new-journal-incarnation", secondRequester.ref),
+        secondRequester.ref)
+      Await.result(control.reads(1), 3.seconds)
+
+      RestartingReplayJournal.completeRead(read = 0, highestSequenceNr = 0L)
+      secondRequester.expectNoMessage(100.millis)
+
+      RestartingReplayJournal.completeRead(read = 1, highestSequenceNr = 0L)
+      val response = secondRequester.expectMsgType[ReplayBatchResponse]
+      response.actorInstanceId shouldBe 32
+      response.response shouldBe RecoverySuccess(0L)
+      secondRequester.lastSender shouldBe system.deadLetters
+    }
+  }
+}
diff --git 
a/persistence/src/test/scala/org/apache/pekko/persistence/journal/ReplayFilterSpec.scala
 
b/persistence/src/test/scala/org/apache/pekko/persistence/journal/ReplayFilterSpec.scala
index dd792984f1..ab4890770a 100644
--- 
a/persistence/src/test/scala/org/apache/pekko/persistence/journal/ReplayFilterSpec.scala
+++ 
b/persistence/src/test/scala/org/apache/pekko/persistence/journal/ReplayFilterSpec.scala
@@ -157,6 +157,33 @@ class ReplayFilterSpec extends PekkoSpec with 
ImplicitSender {
   }
 
   "ReplayFilter in Fail mode" must {
+    "keep its buffer and writer state across bounded replay batches" in {
+      val journalProbe = TestProbe()
+      val filter = system.actorOf(
+        ReplayFilter.props(testActor, mode = Fail, windowSize = 100, 
maxOldWriters = 10, debugEnabled = false))
+      val m2b = m2.copy(persistent = m2.persistent.update(writerUuid = 
writerB))
+      val batchReady = ReplayBatchReady(1L)
+
+      filter.tell(m1, journalProbe.ref)
+      filter.tell(m2b, journalProbe.ref)
+      filter.tell(batchReady, journalProbe.ref)
+
+      // The marker passes through without flushing the look-ahead buffer.
+      expectMsg(batchReady)
+
+      EventFilter.error(start = "Invalid replayed event", occurrences = 
1).intercept {
+        filter.tell(m2, journalProbe.ref) // writerA is now an old writer
+        expectMsgType[ReplayMessagesFailure].cause.getClass should 
be(classOf[IllegalStateException])
+
+        // The failed filter cancels the replay session when the journal 
finishes the current batch.
+        filter.tell(batchReady, journalProbe.ref)
+        journalProbe.expectMsg(ReplayBatchCancel(1L))
+      }
+
+      watch(filter)
+      expectTerminated(filter)
+    }
+
     "fail when message with same seqNo from old overlapping writer" in {
       val filter = system.actorOf(
         ReplayFilter.props(testActor, mode = Fail, windowSize = 100, 
maxOldWriters = 10, debugEnabled = false))


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

Reply via email to