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 1fa250d450 Stop PersistentActor by default on RecoveryCompleted
failure (#3359)
1fa250d450 is described below
commit 1fa250d450e18bbebd0374378b6e2a8069473e46
Author: He-Pin(kerr) <[email protected]>
AuthorDate: Fri Jul 17 19:56:29 2026 +0800
Stop PersistentActor by default on RecoveryCompleted failure (#3359)
Motivation:
A classic PersistentActor can restart indefinitely under the default
supervision strategy when its RecoveryCompleted handler throws.
Modification:
Wrap exceptions from the RecoveryCompleted handler in
ActorInitializationException before passing them to the parent supervisor. Keep
recovery permit cleanup in the existing outer catch, add directional tests for
the default Stop decision and a custom parent Restart decision, and document
the supervision contract.
Result:
The default strategy stops the actor instead of looping, while a custom
parent supervisor can still resume or restart it. The original failure remains
available as the exception cause.
Tests:
- sbt "persistence / Test / testOnly
org.apache.pekko.persistence.RecoveryPermitterSpec" (7 passed; restart test
failed before the fix)
- sbt "persistence / Test / test" (62 passed)
- sbt docs/paradox
- sbt headerCreateAll
- sbt +headerCheckAll
- sbt checkCodeStyle
- sbt validatePullRequest (24m44s; failed only on unchanged
baseline/environment issues: x86 LevelDB JNI on macOS ARM64 and
IntegrationDocTest using pekko.dispatch.UnboundedMailbox)
- sbt sortImports (not completed: repository Scalafix rule failed with
scala.meta NoSuchMethodError)
- scalafmt --list --mode diff-ref=origin/main
- git diff --check
- Qoder stdout review: No must-fix findings
References:
Fixes #3234, Fixes #3249
---
docs/src/main/paradox/persistence.md | 4 ++
.../apache/pekko/persistence/Eventsourced.scala | 14 ++++--
.../pekko/persistence/RecoveryPermitterSpec.scala | 57 ++++++++++++++++++----
3 files changed, 62 insertions(+), 13 deletions(-)
diff --git a/docs/src/main/paradox/persistence.md
b/docs/src/main/paradox/persistence.md
index 36d0f92514..2c0c696760 100644
--- a/docs/src/main/paradox/persistence.md
+++ b/docs/src/main/paradox/persistence.md
@@ -215,6 +215,10 @@ The actor will always receive a `RecoveryCompleted`
message, even if there are n
in the journal and the snapshot store is empty, or if it's a new persistent
actor with a previously
unused `persistenceId`.
+If handling `RecoveryCompleted` throws an exception, the failure is reported
to the parent supervisor
+as an `ActorInitializationException`. The default supervision strategy stops
the actor, while a custom
+supervision strategy may resume or restart it.
+
If there is a problem with recovering the state of the actor from the journal,
`onRecoveryFailure`
is called (logging the error by default) and the actor will be stopped.
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 e0593256ab..ccdb90c9eb 100644
--- a/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
+++ b/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
@@ -22,7 +22,7 @@ import scala.concurrent.duration.FiniteDuration
import scala.util.control.NonFatal
import org.apache.pekko
-import pekko.actor.{ Actor, ActorCell, DeadLetter, StashOverflowException }
+import pekko.actor.{ Actor, ActorCell, ActorInitializationException,
DeadLetter, StashOverflowException }
import pekko.annotation.{ InternalApi, InternalStableApi }
import pekko.dispatch.Envelope
import pekko.event.{ Logging, LoggingAdapter }
@@ -792,9 +792,15 @@ private[persistence] trait Eventsourced
sequenceNr = highestSeqNr
setLastSequenceNr(highestSeqNr)
_recoveryRunning = false
- try Eventsourced.super.aroundReceive(recoveryBehavior,
RecoveryCompleted)
- finally transitToProcessingState() // in finally in case
exception and resume strategy
- // if exception from RecoveryCompleted the permit is returned in
below catch
+ try {
+ Eventsourced.super.aroundReceive(recoveryBehavior,
RecoveryCompleted)
+ } catch {
+ case NonFatal(t) =>
+ throw ActorInitializationException(
+ self,
+ s"Exception in receiveRecover when handling
RecoveryCompleted for persistenceId [$persistenceId]",
+ t)
+ } finally transitToProcessingState()
returnRecoveryPermit()
case ReplayMessagesFailure(cause) =>
timeoutCancellable.cancel()
diff --git
a/persistence/src/test/scala/org/apache/pekko/persistence/RecoveryPermitterSpec.scala
b/persistence/src/test/scala/org/apache/pekko/persistence/RecoveryPermitterSpec.scala
index bf4a91f98b..46af4842a2 100644
---
a/persistence/src/test/scala/org/apache/pekko/persistence/RecoveryPermitterSpec.scala
+++
b/persistence/src/test/scala/org/apache/pekko/persistence/RecoveryPermitterSpec.scala
@@ -27,6 +27,7 @@ import com.typesafe.config.ConfigFactory
object RecoveryPermitterSpec {
class TestExc extends RuntimeException("simulated exc") with NoStackTrace
+ final case class Supervised(cause: Throwable)
def testProps(name: String, probe: ActorRef, throwFromRecoveryCompleted:
Boolean = false): Props =
Props(new TestPersistentActor(name, probe, throwFromRecoveryCompleted))
@@ -46,12 +47,30 @@ object RecoveryPermitterSpec {
if (throwFromRecoveryCompleted)
throw new TestExc
}
+
override def receiveCommand: Receive = {
case "stop" =>
context.stop(self)
}
}
+ class RestartOnceSupervisor(childProps: Props, probe: ActorRef) extends
Actor {
+ private var failures = 0
+
+ override val supervisorStrategy = OneForOneStrategy(loggingEnabled =
false) {
+ case cause: ActorInitializationException =>
+ failures += 1
+ probe ! Supervised(cause)
+ if (failures == 1) SupervisorStrategy.Restart
+ else SupervisorStrategy.Stop
+ }
+
+ override def preStart(): Unit =
+ probe ! context.actorOf(childProps)
+
+ override def receive: Receive = Actor.emptyBehavior
+ }
+
}
class RecoveryPermitterSpec extends
PersistenceSpec(ConfigFactory.parseString(s"""
@@ -62,7 +81,7 @@ class RecoveryPermitterSpec extends
PersistenceSpec(ConfigFactory.parseString(s"
import RecoveryPermitter._
import RecoveryPermitterSpec._
- system.eventStream.publish(TestEvent.Mute(EventFilter[TestExc]()))
+
system.eventStream.publish(TestEvent.Mute(EventFilter[ActorInitializationException]()))
val permitter = Persistence(system).recoveryPermitter
val p1 = TestProbe()
@@ -171,23 +190,43 @@ class RecoveryPermitterSpec extends
PersistenceSpec(ConfigFactory.parseString(s"
permitter.tell(ReturnRecoveryPermit, p4.ref)
}
- "return permit when actor throws from RecoveryCompleted" in {
+ "return permit and stop actor by default when it throws from
RecoveryCompleted" in {
requestPermit(p1)
requestPermit(p2)
val persistentActor = system.actorOf(testProps("p3", p3.ref,
throwFromRecoveryCompleted = true))
+ p3.watch(persistentActor)
p3.expectMsg(RecoveryCompleted)
p3.expectMsg("postStop")
- // it's restarting
- (1 to 5).foreach { _ =>
+ p3.expectTerminated(persistentActor)
+
+ requestPermit(p4)
+
+ permitter.tell(ReturnRecoveryPermit, p1.ref)
+ permitter.tell(ReturnRecoveryPermit, p2.ref)
+ permitter.tell(ReturnRecoveryPermit, p4.ref)
+ }
+
+ "allow the parent supervisor to restart an actor that throws from
RecoveryCompleted" in {
+ requestPermit(p1)
+ requestPermit(p2)
+
+ val supervisor = system.actorOf(
+ Props(new RestartOnceSupervisor(testProps("p3", p3.ref,
throwFromRecoveryCompleted = true), p3.ref)))
+ val persistentActor = p3.expectMsgType[ActorRef]
+ p3.watch(persistentActor)
+
+ (1 to 2).foreach { _ =>
p3.expectMsg(RecoveryCompleted)
+ p3.lastSender shouldBe persistentActor
+ p3.expectMsgPF(hint = "supervised ActorInitializationException caused
by TestExc") {
+ case Supervised(cause: ActorInitializationException)
+ if cause.getActor == persistentActor &&
cause.getCause.isInstanceOf[TestExc] =>
+ }
p3.expectMsg("postStop")
}
- // stop it
- val stopProbe = TestProbe()
- stopProbe.watch(persistentActor)
- system.stop(persistentActor)
- stopProbe.expectTerminated(persistentActor)
+ p3.expectTerminated(persistentActor)
+ system.stop(supervisor)
requestPermit(p4)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]