This is an automated email from the ASF dual-hosted git repository.
pjfanning 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 72e663f9d2 add akka PRs from v2.8.4 (#3432)
72e663f9d2 is described below
commit 72e663f9d2764c1a19613e915e20b6c3c11d9d9b
Author: PJ Fanning <[email protected]>
AuthorDate: Sat Aug 15 10:32:38 2026 +0100
add akka PRs from v2.8.4 (#3432)
compile
Update EventWriter.scala
Update EventWriterSpec.scala
scalafmt
doc fixes
javafmt
Delete diff.txt
---
docs/src/main/paradox/io-dns.md | 2 +-
docs/src/main/paradox/split-brain-resolver.md | 12 +-
.../typed/internal/EventWriterSpec.scala | 214 +++++++++++
.../typed/scaladsl/EventSourcedBehaviorSpec.scala | 69 +++-
.../src/main/resources/reference.conf | 8 +
.../persistence/typed/internal/EventWriter.scala | 391 +++++++++++++++++++++
.../typed/internal/ReplayingEvents.scala | 5 +-
.../persistence/typed/state/javadsl/Effect.scala | 7 +-
.../typed/DurableStatePersistentBehaviorTest.java | 5 +
persistence/src/main/resources/reference.conf | 3 +
.../apache/pekko/persistence/Eventsourced.scala | 1 +
.../persistence/journal/inmem/InmemJournal.scala | 14 +-
.../pekko/persistence/PersistentActorSpec.scala | 34 ++
13 files changed, 748 insertions(+), 17 deletions(-)
diff --git a/docs/src/main/paradox/io-dns.md b/docs/src/main/paradox/io-dns.md
index 7f0095449b..adb48d6c7b 100644
--- a/docs/src/main/paradox/io-dns.md
+++ b/docs/src/main/paradox/io-dns.md
@@ -27,7 +27,7 @@ Users should pick one of the built in extensions.
@@@
-Pekko DNS is a pluggable way to interact with DNS. Implementations much
implement `org.apache.pekko.io.DnsProvider` and provide a configuration
+Pekko DNS is a pluggable way to interact with DNS. Implementations must
implement `org.apache.pekko.io.DnsProvider` and provide a configuration
block that specifies the implementation via `provider-object`.
@@@ note { title="DNS via Pekko Discovery" }
diff --git a/docs/src/main/paradox/split-brain-resolver.md
b/docs/src/main/paradox/split-brain-resolver.md
index a92fa7218b..ac52324fd5 100644
--- a/docs/src/main/paradox/split-brain-resolver.md
+++ b/docs/src/main/paradox/split-brain-resolver.md
@@ -227,22 +227,22 @@ of 4 and 5 nodes the side with 5 nodes will survive and
the other 4 nodes will b
in the 5 node cluster, no more failures can be handled, because the remaining
cluster size would be
less than 5. In the case of another failure in that 5 node cluster all nodes
will be downed.
-Therefore it is important that you join new nodes when old nodes have been
removed.
+Therefore, it is important that you join new nodes when old nodes have been
removed.
Another consequence of this is that if there are unreachable nodes when
starting up the cluster,
before reaching this limit, the cluster may shut itself down immediately. This
is not an issue
if you start all nodes at approximately the same time or use the
`pekko.cluster.min-nr-of-members`
-to define required number of members before the leader changes member status
of 'Joining' members to 'Up'
+to define required number of members before the leader changes member status
of 'Joining' members to 'Up'.
You can tune the timeout after which downing decisions are made using the
`stable-after` setting.
You should not add more members to the cluster than **quorum-size * 2 - 1**. A
warning is logged
-if this recommendation is violated. If the exceeded cluster size remains when
a SBR decision is
+if this recommendation is violated. If the exceeded cluster size remains when
an SBR decision is
needed it will down all nodes because otherwise there is a risk that both
sides may down each
other and thereby form two separate clusters.
-For rolling updates it's best to leave the cluster gracefully via
+For rolling updates, it's best to leave the cluster gracefully via
@ref:[Coordinated Shutdown](coordinated-shutdown.md) (SIGTERM).
-For successful leaving SBR will not be used (no downing) but if there is an
unreachability problem
+For successful leaving, SBR will not be used (no downing) but if there is an
unreachability problem
at the same time as the rolling update is in progress there could be an SBR
decision. To avoid that
the total number of members limit is not exceeded during the rolling update
it's recommended to
leave and fully remove one node before adding a new one, when using
`static-quorum`.
@@ -380,7 +380,7 @@ See also configuration and additional dependency in
[Kubernetes Lease]($pekko.do
## Indirectly connected nodes
-In a malfunctional network there can be situations where nodes are observed as
unreachable via some network
+In a malfunctioning network there can be situations where nodes are observed
as unreachable via some network
links but they are still indirectly connected via other nodes, i.e. it's not a
clean network partition (or node crash).
When this situation is detected the Split Brain Resolvers will keep fully
connected nodes and down all the indirectly
diff --git
a/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/internal/EventWriterSpec.scala
b/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/internal/EventWriterSpec.scala
new file mode 100644
index 0000000000..918fb4b4b4
--- /dev/null
+++
b/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/internal/EventWriterSpec.scala
@@ -0,0 +1,214 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.typed.internal
+
+import scala.concurrent.ExecutionContext
+import scala.concurrent.Future
+import scala.concurrent.duration.DurationInt
+
+import org.apache.pekko
+import pekko.actor.testkit.typed.scaladsl.LogCapturing
+import pekko.actor.testkit.typed.scaladsl.ScalaTestWithActorTestKit
+import pekko.pattern.StatusReply
+import pekko.persistence.AtomicWrite
+import pekko.persistence.JournalProtocol
+
+import com.typesafe.config.ConfigFactory
+import org.scalatest.wordspec.AnyWordSpecLike
+
+object EventWriterSpec {
+ def config =
+ ConfigFactory
+ .parseString("""
+ pekko.persistence.journal.inmem.delay-writes=10ms
+ """)
+ .withFallback(ConfigFactory.load())
+ .resolve()
+}
+
+class EventWriterSpec
+ extends ScalaTestWithActorTestKit(EventWriterSpec.config)
+ with AnyWordSpecLike
+ with LogCapturing {
+
+ private val settings = EventWriter.EventWriterSettings(10, 5.seconds)
+ implicit val ec: ExecutionContext = testKit.system.executionContext
+
+ "The event writer" should {
+
+ "handle duplicates" in new TestSetup {
+ sendWrite(1)
+ journalAckWrite()
+ clientExpectSuccess(1)
+
+ // should also be ack:ed
+ sendWrite(1)
+ journalAckWrite()
+ clientExpectSuccess(1)
+ }
+
+ "handle batched duplicates" in new TestSetup {
+ // first write
+ sendWrite(1)
+ // first batch
+ for (n <- 2L to 10L) {
+ sendWrite(n)
+ }
+ // 0 will be written directly
+ journalAckWrite() should ===(1)
+ clientExpectSuccess(1)
+
+ // completing 1 triggers write of batch with 0-9
+ // second
+ for (n <- 1L to 10L) {
+ sendWrite(n)
+ }
+ // batch 0-9 in flight, writes in the meanwhile go in a new batch
+ journalAckWrite() should ===(9)
+ journalFailWrite("duplicate") should ===(10)
+ journalHighestSeqNr(10L)
+
+ clientExpectSuccess(19)
+ }
+
+ "handle batches with half duplicates" in new TestSetup {
+ for (n <- 1L to 10L) {
+ sendWrite(n)
+ }
+ journalAckWrite() should ===(1)
+ journalAckWrite() should ===(9)
+ clientExpectSuccess(10)
+
+ for (n <- 5L until 15L) {
+ sendWrite(n)
+ }
+ journalFailWrite("duplicate") should ===(1) // seq nr 5
+ journalHighestSeqNr(10L)
+ journalFailWrite("duplicate") should ===(9) // batch of 6-15
+ journalHighestSeqNr(10L)
+ journalAckWrite() should ===(4) // new write of 11-15 (non duplicates)
+
+ // all writes succeeded
+ clientExpectSuccess(10)
+ }
+
+ "pass real errors from journal back" in new TestSetup {
+ sendWrite(1L)
+ journalFailWrite("error error")
+ // duplicate handling will ask for highest seq nr, can't know it is an
actual error
+ journalHighestSeqNr(0L)
+ val response = clientProbe.receiveMessage()
+ response.isError should ===(true)
+ response.getError.getMessage should ===("Journal write failed")
+ }
+
+ "ignores old failures when replay triggered" in new TestSetup {
+ sendWrite(1L) // triggers write
+
+ sendWrite(1L) // goes into batch
+ sendWrite(2L)
+ journalAckWrite()
+
+ val firstWrite =
fakeJournal.expectMessageType[JournalProtocol.WriteMessages]
+ val payloads = firstWrite.messages.head.asInstanceOf[AtomicWrite].payload
+ // signal first failure, triggers HighestSeq
+ firstWrite.persistentActor ! JournalProtocol.WriteMessageFailure(
+ payloads.head,
+ new RuntimeException("duplicate"),
+ firstWrite.actorInstanceId)
+
+ // replay response triggers partial rewrite, seq nr 2
+ val replayRequest =
fakeJournal.expectMessageType[JournalProtocol.ReplayMessages]
+ replayRequest.persistentActor ! JournalProtocol.RecoverySuccess(1L)
+
+ // but then original write failure arrives for seq nr 2 after sequence
number lookup succeeded
+ firstWrite.persistentActor ! JournalProtocol.WriteMessageFailure(
+ payloads.tail.head,
+ new RuntimeException("duplicate"),
+ firstWrite.actorInstanceId)
+ firstWrite.persistentActor ! JournalProtocol.WriteMessagesFailed(
+ new RuntimeException("duplicate"),
+ payloads.size)
+
+ // ack the partial retry
+ journalAckWrite() should ===(1)
+
+ clientExpectSuccess(3)
+ }
+
+ "handle writes to many pids" in {
+ val writer = spawn(EventWriter("pekko.persistence.journal.inmem",
settings))
+ val probe = createTestProbe[StatusReply[EventWriter.WriteAck]]()
+ (0 to 1000).map { pidN =>
+ Future {
+ for (n <- 0 until 20) {
+ writer ! EventWriter.Write(s"pid$pidN", n.toLong, n.toString,
None, Set.empty, probe.ref)
+ }
+ }
+ }
+ probe.receiveMessages(20 * 1000, 20.seconds)
+ }
+ }
+
+ trait TestSetup {
+ def pid1 = "pid1"
+ val fakeJournal = createTestProbe[JournalProtocol.Message]()
+ val writer = spawn(EventWriter(fakeJournal.ref, settings))
+ val clientProbe = createTestProbe[StatusReply[EventWriter.WriteAck]]()
+
+ def sendWrite(seqNr: Long, pid: String = pid1): Unit = {
+ writer ! EventWriter.Write(pid, seqNr, seqNr.toString, None, Set.empty,
clientProbe.ref)
+ }
+
+ def journalAckWrite(pid: String = pid1): Int = {
+ val write = fakeJournal.expectMessageType[JournalProtocol.WriteMessages]
+ write.messages should have size 1
+ val atomicWrite = write.messages.head.asInstanceOf[AtomicWrite]
+ atomicWrite.payload.foreach { repr =>
+ repr.persistenceId should ===(pid)
+ write.persistentActor ! JournalProtocol.WriteMessageSuccess(repr,
write.actorInstanceId)
+ }
+ write.persistentActor ! JournalProtocol.WriteMessagesSuccessful
+ atomicWrite.payload.size
+ }
+
+ def journalFailWrite(reason: String, pid: String = pid1): Int = {
+ val write = fakeJournal.expectMessageType[JournalProtocol.WriteMessages]
+ write.messages should have size 1
+ val atomicWrite = write.messages.head.asInstanceOf[AtomicWrite]
+ atomicWrite.payload.foreach { repr =>
+ repr.persistenceId should ===(pid)
+ write.persistentActor ! JournalProtocol.WriteMessageFailure(
+ repr,
+ new RuntimeException(reason),
+ write.actorInstanceId)
+ }
+ write.persistentActor ! JournalProtocol.WriteMessagesFailed(
+ new RuntimeException(reason),
+ atomicWrite.payload.size)
+ atomicWrite.payload.size
+ }
+
+ def journalHighestSeqNr(highestSeqNr: Long): Unit = {
+ val replay =
fakeJournal.expectMessageType[JournalProtocol.ReplayMessages]
+ replay.persistentActor ! JournalProtocol.RecoverySuccess(highestSeqNr)
+ }
+
+ def clientExpectSuccess(n: Int): Unit = {
+ clientProbe.receiveMessages(n).foreach { reply =>
+ reply.isSuccess should be(true)
+ }
+ }
+ }
+}
diff --git
a/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorSpec.scala
b/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorSpec.scala
index 0a65ea9645..efc9d43093 100644
---
a/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorSpec.scala
+++
b/persistence-typed-tests/src/test/scala/org/apache/pekko/persistence/typed/scaladsl/EventSourcedBehaviorSpec.scala
@@ -37,8 +37,11 @@ import pekko.actor.typed.scaladsl.ActorContext
import pekko.actor.typed.scaladsl.Behaviors
import pekko.persistence.{ SnapshotMetadata => ClassicSnapshotMetadata }
import pekko.persistence.{ SnapshotSelectionCriteria =>
ClassicSnapshotSelectionCriteria }
+import pekko.persistence.FilteredPayload
import pekko.persistence.SelectedSnapshot
import pekko.persistence.journal.inmem.InmemJournal
+import pekko.persistence.typed.EventAdapter
+import pekko.persistence.typed.EventSeq
import pekko.persistence.query.EventEnvelope
import pekko.persistence.query.Offset
import pekko.persistence.query.PersistenceQuery
@@ -120,9 +123,12 @@ object EventSourcedBehaviorSpec {
case object LogThenStop extends Command
case object Fail extends Command
case object StopIt extends Command
+ case object PersistFilteredEvent extends Command
+ final case class GetLastSequenceNumber(replyTo: ActorRef[Long]) extends
Command
sealed trait Event extends CborSerializable
final case class Incremented(delta: Int) extends Event
+ case object FilteredEvent extends Event
final case class State(value: Int, history: Vector[Int]) extends
CborSerializable
@@ -274,19 +280,48 @@ object EventSourcedBehaviorSpec {
case StopIt =>
Effect.none.thenStop()
+ case PersistFilteredEvent =>
+ // FilteredEvent will be converted FilteredPayload in eventAdapter
+ Effect.persist(FilteredEvent)
+
+ case GetLastSequenceNumber(replyTo) =>
+ replyTo ! EventSourcedBehavior.lastSequenceNumber(ctx)
+ Effect.none
+
},
eventHandler = (state, evt) =>
evt match {
case Incremented(delta) =>
probe ! ((state, evt))
State(state.value + delta, state.history :+ state.value)
- }).receiveSignal {
- case (_, RecoveryCompleted) => ()
- case (_, SnapshotCompleted(metadata)) =>
- snapshotProbe ! Success(metadata)
- case (_, SnapshotFailed(_, failure)) =>
- snapshotProbe ! Failure(failure)
- }
+ case FilteredEvent =>
+ state
+ })
+ .eventAdapter(new EventAdapter[Event, Any] {
+ override def toJournal(e: Event): Any =
+ e match {
+ case FilteredEvent => FilteredPayload
+ case _ => e
+ }
+
+ override def manifest(event: Event): String = ""
+
+ override def fromJournal(p: Any, manifest: String): EventSeq[Event] =
+ p match {
+ case FilteredPayload =>
+ throw new IllegalStateException("Unexpected FilteredPayload")
+ case e: Event => EventSeq.single(e)
+ case _ =>
+ throw new IllegalStateException(s"Unexpected event type $p")
+ }
+ })
+ .receiveSignal {
+ case (_, RecoveryCompleted) => ()
+ case (_, SnapshotCompleted(metadata)) =>
+ snapshotProbe ! Success(metadata)
+ case (_, SnapshotFailed(_, failure)) =>
+ snapshotProbe ! Failure(failure)
+ }
}
}
@@ -332,6 +367,26 @@ class EventSourcedBehaviorSpec
probe.expectMessage(State(4, Vector(0, 1, 2, 3)))
}
+ "exclude FilteredEvent in replay of persisted events" in {
+ val pid = nextPid()
+ val c = spawn(counter(pid))
+
+ val probe = TestProbe[State]()
+ val seqNrProbe = TestProbe[Long]()
+ c ! Increment
+ c ! PersistFilteredEvent
+ c ! Increment
+ c ! GetValue(probe.ref)
+ probe.expectMessage(10.seconds, State(2, Vector(0, 1)))
+
+ val c2 = spawn(counter(pid))
+ c2 ! Increment
+ c2 ! GetValue(probe.ref)
+ probe.expectMessage(State(3, Vector(0, 1, 2)))
+ c2 ! GetLastSequenceNumber(seqNrProbe.ref)
+ seqNrProbe.expectMessage(4L) // seqNr 2 was used for FilteredPayload
+ }
+
"handle Terminated signal" in {
val c = spawn(counter(nextPid()))
val probe = TestProbe[State]()
diff --git a/persistence-typed/src/main/resources/reference.conf
b/persistence-typed/src/main/resources/reference.conf
index 2513dac159..c810d71c01 100644
--- a/persistence-typed/src/main/resources/reference.conf
+++ b/persistence-typed/src/main/resources/reference.conf
@@ -54,6 +54,14 @@ pekko.persistence.typed {
# recursively (that was the default before this setting was introduced).
That might cause a stack
# overflow in case there are many messages to unstash.
recurse-when-unstashing-read-only-commands = false
+
+ event-writer {
+ # The maximum number of events to batch together when writing to the
journal through the event writer
+ max-batch-size = 10
+ # The event-writer occasionally needs to ask the journal about highest
sequence number to handle duplicate
+ # writes, this timeout is for that interaction
+ ask-timeout = 20s
+ }
}
pekko.reliable-delivery {
diff --git
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala
new file mode 100644
index 0000000000..4bb5abd34d
--- /dev/null
+++
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/internal/EventWriter.scala
@@ -0,0 +1,391 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * license agreements; and to You under the Apache License, version 2.0:
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * This file is part of the Apache Pekko project, which was derived from Akka.
+ */
+
+/*
+ * Copyright (C) 2009-2023 Lightbend Inc. <https://www.lightbend.com>
+ */
+
+package org.apache.pekko.persistence.typed.internal
+
+import java.net.URLEncoder
+import java.util.UUID
+import java.util.concurrent.ConcurrentHashMap
+
+import scala.concurrent.duration.FiniteDuration
+import scala.jdk.DurationConverters._
+import scala.util.Failure
+import scala.util.Success
+
+import org.apache.pekko
+import pekko.actor.typed.ActorRef
+import pekko.actor.typed.ActorSystem
+import pekko.actor.typed.Behavior
+import pekko.actor.typed.Extension
+import pekko.actor.typed.ExtensionId
+import pekko.actor.typed.SupervisorStrategy
+import pekko.actor.typed.scaladsl.Behaviors
+import pekko.actor.typed.scaladsl.LoggerOps
+import pekko.actor.typed.scaladsl.adapter.ClassicActorRefOps
+import pekko.actor.typed.scaladsl.adapter.TypedActorRefOps
+import pekko.annotation.InternalStableApi
+import pekko.pattern.StatusReply
+import pekko.persistence.AtomicWrite
+import pekko.persistence.JournalProtocol
+import pekko.persistence.Persistence
+import pekko.persistence.PersistentRepr
+import pekko.persistence.journal.Tagged
+import pekko.util.Timeout
+
+/**
+ * INTERNAL API
+ */
+@InternalStableApi
+private[pekko] object EventWriterExtension extends
ExtensionId[EventWriterExtension] {
+ def createExtension(system: ActorSystem[?]): EventWriterExtension = new
EventWriterExtension(system)
+
+ def get(system: ActorSystem[?]): EventWriterExtension = apply(system)
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalStableApi
+private[pekko] class EventWriterExtension(system: ActorSystem[?]) extends
Extension {
+
+ private val settings = EventWriter.EventWriterSettings(system)
+ private val writersPerJournalId = new ConcurrentHashMap[String,
ActorRef[EventWriter.Command]]()
+
+ def writerForJournal(journalId: Option[String]):
ActorRef[EventWriter.Command] =
+ writersPerJournalId.computeIfAbsent(
+ journalId.getOrElse(""),
+ { _ =>
+ system.systemActorOf(
+ EventWriter(journalId.getOrElse(""), settings),
+ s"EventWriter-${URLEncoder.encode(journalId.getOrElse("default"),
"UTF-8")}")
+ })
+
+}
+
+/**
+ * INTERNAL API
+ */
+@InternalStableApi
+private[pekko] object EventWriter {
+
+ type SeqNr = Long
+ type Pid = String
+
+ object EventWriterSettings {
+ def apply(system: ActorSystem[?]): EventWriterSettings =
+ EventWriterSettings(
+ maxBatchSize =
system.settings.config.getInt("pekko.persistence.typed.event-writer.max-batch-size"),
+ askTimeout =
system.settings.config.getDuration("pekko.persistence.typed.event-writer.ask-timeout").toScala)
+
+ }
+ final case class EventWriterSettings(maxBatchSize: Int, askTimeout:
FiniteDuration)
+
+ sealed trait Command
+ final case class Write(
+ persistenceId: Pid,
+ sequenceNumber: SeqNr,
+ event: Any,
+ metadata: Option[Any],
+ tags: Set[String],
+ replyTo: ActorRef[StatusReply[WriteAck]])
+ extends Command
+ final case class WriteAck(persistenceId: Pid, sequenceNumber: SeqNr)
+
+ private case class MaxSeqNrForPid(persistenceId: Pid, sequenceNumber: SeqNr,
originalErrorDesc: String)
+ extends Command
+
+ private def emptyWaitingForWrite = Vector.empty[(PersistentRepr,
ActorRef[StatusReply[WriteAck]])]
+ private case class StateForPid(
+ waitingForReply: Map[SeqNr, (PersistentRepr,
ActorRef[StatusReply[WriteAck]])],
+ waitingForWrite: Vector[(PersistentRepr,
ActorRef[StatusReply[WriteAck]])] = emptyWaitingForWrite,
+ writeErrorHandlingInProgress: Boolean = false,
+ currentTransactionId: Int = 0)
+
+ def apply(journalPluginId: String, eventWriterSettings:
EventWriterSettings): Behavior[Command] =
+ Behaviors
+ .supervise(Behaviors.setup[Command] { context =>
+ val journal =
Persistence(context.system.classicSystem).journalFor(journalPluginId)
+ context.log.debug("Event writer for journal [{}] starting up",
journalPluginId)
+ apply(journal.toTyped, eventWriterSettings)
+ })
+ .onFailure[Exception](SupervisorStrategy.restart)
+
+ def apply(journal: ActorRef[JournalProtocol.Message], eventWriterSettings:
EventWriterSettings): Behavior[Command] =
+ Behaviors
+ .setup[AnyRef] { context =>
+ val writerUuid = UUID.randomUUID().toString
+
+ var perPidWriteState = Map.empty[Pid, StateForPid]
+ implicit val askTimeout: Timeout = eventWriterSettings.askTimeout
+
+ def sendToJournal(transactionId: Int, reprs: Vector[PersistentRepr]) =
{
+ journal ! JournalProtocol.WriteMessages(
+ AtomicWrite(reprs) :: Nil,
+ context.self.toClassic,
+ // Note: we use actorInstanceId to correlate replies from one
write request
+ actorInstanceId = transactionId)
+ }
+
+ def handleUpdatedStateForPid(pid: Pid, newStateForPid: StateForPid):
Unit = {
+ if (newStateForPid.waitingForReply.nonEmpty) {
+ // more waiting replyTo before we could batch it or scrap the entry
+ perPidWriteState = perPidWriteState.updated(pid, newStateForPid)
+ } else {
+ if (newStateForPid.waitingForWrite.isEmpty) {
+ perPidWriteState = perPidWriteState - pid
+ } else {
+ // batch waiting for pid
+ val newReplyTo = newStateForPid.waitingForWrite.map {
+ case (repr, replyTo) => (repr.sequenceNr, (repr, replyTo))
+ }.toMap
+ val updatedState =
+ newStateForPid.copy(
+ newReplyTo,
+ emptyWaitingForWrite,
+ currentTransactionId = newStateForPid.currentTransactionId +
1)
+
+ if (context.log.isTraceEnabled())
+ context.log.traceN(
+ "Writing batch of {} events for pid [{}], seq nrs [{}-{}],
tx id [{}]",
+ newStateForPid.waitingForWrite.size,
+ pid,
+ newStateForPid.waitingForWrite.head._1.sequenceNr,
+ newStateForPid.waitingForWrite.last._1.sequenceNr,
+ updatedState.currentTransactionId)
+
+ val batch = newStateForPid.waitingForWrite.map { case (repr, _)
=> repr }
+ sendToJournal(updatedState.currentTransactionId, batch)
+ perPidWriteState = perPidWriteState.updated(pid, updatedState)
+
+ }
+ }
+ }
+
+ def handleJournalResponse(response: JournalProtocol.Response):
Behavior[AnyRef] =
+ response match {
+ case JournalProtocol.WriteMessageSuccess(message, transactionId) =>
+ val pid = message.persistenceId
+ val sequenceNr = message.sequenceNr
+ perPidWriteState.get(pid) match {
+ case None =>
+ context.log.debugN(
+ "Got write success reply for event with no previous state
for pid, ignoring (pid [{}], seq nr [{}], tx id [{}])",
+ pid,
+ sequenceNr,
+ transactionId)
+ case Some(stateForPid) =>
+ if (transactionId == stateForPid.currentTransactionId) {
+ stateForPid.waitingForReply.get(sequenceNr) match {
+ case None =>
+ context.log.debugN(
+ "Got write error reply for event with no waiting
request for seq nr, ignoring (pid [{}], seq nr [{}], tx id [{}])",
+ pid,
+ sequenceNr,
+ transactionId)
+ case Some((_, waiting)) =>
+ context.log.trace2(
+ "Successfully wrote event persistence id [{}],
sequence nr [{}]",
+ pid,
+ message.sequenceNr)
+ waiting ! StatusReply.success(WriteAck(pid,
sequenceNr))
+ val newState = stateForPid.copy(waitingForReply =
stateForPid.waitingForReply - sequenceNr)
+ handleUpdatedStateForPid(pid, newState)
+ }
+ } else {
+ if (context.log.isTraceEnabled) {
+ context.log.traceN(
+ "Got reply for old tx id [{}] (current [{}]) for pid
[{}], ignoring",
+ transactionId,
+ stateForPid.currentTransactionId,
+ pid)
+ }
+ }
+ }
+ Behaviors.same
+
+ case JournalProtocol.WriteMessageFailure(message, error,
transactionId) =>
+ val pid = message.persistenceId
+ val sequenceNr = message.sequenceNr
+ perPidWriteState.get(pid) match {
+ case None =>
+ context.log.debugN(
+ "Got write error reply for event with no previous state
for pid, ignoring (pid [{}], seq nr [{}], tx id [{}])",
+ pid,
+ sequenceNr,
+ transactionId)
+ case Some(state) =>
+ // write failure could be re-delivery, we need to check
+ state.waitingForReply.get(sequenceNr) match {
+ case None =>
+ context.log.debugN(
+ "Got write error reply for event with no waiting
request for seq nr, ignoring (pid [{}], seq nr [{}], tx id [{}])",
+ pid,
+ sequenceNr,
+ transactionId)
+ case Some(_) =>
+ // quite likely a re-delivery of already persisted
events, the whole batch will fail
+ // check highest seqnr and see if we can ack events
+ if (!state.writeErrorHandlingInProgress &&
state.currentTransactionId == transactionId) {
+ perPidWriteState =
+ perPidWriteState.updated(pid,
state.copy(writeErrorHandlingInProgress = true))
+ context.ask(
+ journal,
+ (replyTo: ActorRef[JournalProtocol.Response]) =>
+ JournalProtocol.ReplayMessages(0L, 0L, 1L, pid,
replyTo.toClassic)) {
+ case
Success(JournalProtocol.RecoverySuccess(highestSequenceNr)) =>
+ MaxSeqNrForPid(pid, highestSequenceNr,
error.getMessage)
+ case Success(unexpected) =>
+ throw new IllegalArgumentException(
+ s"Got unexpected reply from journal
${unexpected.getClass} for pid $pid")
+ case Failure(exception) =>
+ throw new RuntimeException(
+ s"Error finding highest sequence number in
journal for pid $pid",
+ exception)
+ }
+ } else {
+ context.log.traceN(
+ "Ignoring failure for pid [{}], seq nr [{}], tx id
[{}], since write error handling already in progress or old tx id (current tx
id [{}])",
+ pid,
+ sequenceNr,
+ transactionId,
+ state.currentTransactionId)
+ }
+ }
+ }
+ Behaviors.same
+
+ case _ =>
+ // ignore all other journal protocol messages
+ Behaviors.same
+ }
+
+ Behaviors.receiveMessage {
+ case Write(persistenceId, sequenceNumber, event, metadata, tags,
replyTo) =>
+ val payload = if (tags.isEmpty) event else Tagged(event, tags)
+ val repr = PersistentRepr(
+ payload,
+ persistenceId = persistenceId,
+ sequenceNr = sequenceNumber,
+ manifest = "", // adapters would be on the producing side,
already applied
+ writerUuid = writerUuid,
+ sender = pekko.actor.ActorRef.noSender)
+
+ val reprWithMeta = metadata match {
+ case Some(meta) => repr.withMetadata(meta)
+ case _ => repr
+ }
+
+ val newStateForPid =
+ perPidWriteState.get(persistenceId) match {
+ case None =>
+ if (context.log.isTraceEnabled)
+ context.log.traceN(
+ "Writing event persistence id [{}], sequence nr [{}],
payload {}",
+ persistenceId,
+ sequenceNumber,
+ event)
+ sendToJournal(1, Vector(reprWithMeta))
+ StateForPid(
+ Map((reprWithMeta.sequenceNr, (reprWithMeta, replyTo))),
+ emptyWaitingForWrite,
+ currentTransactionId = 1)
+ case Some(state) =>
+ // write in progress for pid, add write to batch and perform
once current write completes
+ if (state.waitingForWrite.size ==
eventWriterSettings.maxBatchSize) {
+ replyTo ! StatusReply.error(
+ s"Max batch reached for pid $persistenceId, at most
${eventWriterSettings.maxBatchSize} writes for " +
+ "the same pid may be in flight at the same time")
+ state
+ } else {
+ if (context.log.isTraceEnabled)
+ context.log.traceN(
+ "Writing event in progress for persistence id [{}],
adding sequence nr [{}], payload {} to batch",
+ persistenceId,
+ sequenceNumber,
+ event)
+ state.copy(waitingForWrite = state.waitingForWrite :+
((reprWithMeta, replyTo)))
+ }
+ }
+ perPidWriteState = perPidWriteState.updated(persistenceId,
newStateForPid)
+ Behaviors.same
+
+ case MaxSeqNrForPid(pid, maxSeqNr, originalErrorDesc) =>
+ // write failed, so we looked up the maxSeqNr to detect if it was
duplicate events, already in journal
+ perPidWriteState.get(pid) match {
+ case None =>
+ context.log.debug2(
+ "Got max seq nr with no waiting previous state for pid,
ignoring (pid [{}], original error desc: {})",
+ pid,
+ originalErrorDesc)
+ case Some(state) =>
+ val sortedSeqs = state.waitingForReply.keys.toSeq.sorted
+ val (alreadyInJournal, needsWrite) =
sortedSeqs.partition(seqNr => seqNr <= maxSeqNr)
+ if (alreadyInJournal.isEmpty) {
+ // error was not about duplicates
+ state.waitingForReply.values.foreach {
+ case (_, replyTo) =>
+ replyTo ! StatusReply.error("Journal write failed")
+ }
+ context.log.warnN(
+ "Failed writing event batch persistence id [{}], sequence
nr [{}-{}]: {}",
+ pid,
+ sortedSeqs.head,
+ sortedSeqs.last,
+ originalErrorDesc)
+ val newState = state.copy(waitingForReply = Map.empty,
writeErrorHandlingInProgress = false)
+ handleUpdatedStateForPid(pid, newState)
+ } else {
+ // ack all already written
+ val stateAfterWritten = alreadyInJournal
+ .foldLeft(state) { (state, seqNr) =>
+ val (_, replyTo) = state.waitingForReply(seqNr)
+ replyTo ! StatusReply.success(WriteAck(pid, seqNr))
+ state.copy(waitingForReply = state.waitingForReply -
seqNr)
+ }
+ .copy(writeErrorHandlingInProgress = false)
+ if (needsWrite.isEmpty) {
+ handleUpdatedStateForPid(pid, stateAfterWritten)
+ } else {
+ // retrigger write for those left if any, note that we do a
+ val reprsToRewrite =
+ stateAfterWritten.waitingForReply.values.map { case
(repr, _) => repr }.toVector
+ if (context.log.isDebugEnabled())
+ context.log.debugN(
+ "Partial batch was duplicates, re-triggering write of
persistence id [{}], sequence nr [{}-{}]",
+ pid,
+ reprsToRewrite.head.sequenceNr,
+ reprsToRewrite.last.sequenceNr)
+ // Not going via batch/handleUpdatedStateForPid here
because adding partial
+ // failure to current waiting could go over batch size
limit
+ val partialRewriteState =
+ stateAfterWritten.copy(currentTransactionId =
stateAfterWritten.currentTransactionId + 1)
+ sendToJournal(partialRewriteState.currentTransactionId,
reprsToRewrite)
+ handleUpdatedStateForPid(pid, partialRewriteState)
+ }
+
+ }
+
+ }
+ Behaviors.same
+
+ case response: JournalProtocol.Response =>
handleJournalResponse(response)
+
+ case unexpected =>
+ context.log.warn("Unexpected message sent to EventWriter [{}],
ignored", unexpected.getClass)
+ Behaviors.same
+
+ }
+ }
+ .narrow[Command]
+
+}
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 50abf44347..3cf16769fe 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
@@ -28,6 +28,7 @@ import pekko.event.Logging
import pekko.persistence._
import pekko.persistence.JournalProtocol._
import pekko.persistence.typed.EmptyEventSeq
+import pekko.persistence.typed.EventSeq
import pekko.persistence.typed.EventsSeq
import pekko.persistence.typed.RecoveryCompleted
import pekko.persistence.typed.RecoveryFailed
@@ -138,7 +139,9 @@ private[pekko] final class ReplayingEvents[C, E, S](
case ReplayedMessage(repr) =>
var eventForErrorReporting: OptionVal[Any] = OptionVal.None
try {
- val eventSeq = setup.eventAdapter.fromJournal(repr.payload,
repr.manifest)
+ val eventSeq =
+ if (repr.payload == FilteredPayload) EventSeq.empty // ignore
FilteredPayload
+ else setup.eventAdapter.fromJournal(repr.payload, repr.manifest)
def handleEvent(event: E): Unit = {
eventForErrorReporting = OptionVal.Some(event)
state = state.copy(seqNr = repr.sequenceNr, eventsReplayed =
state.eventsReplayed + 1)
diff --git
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/javadsl/Effect.scala
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/javadsl/Effect.scala
index 95369627ac..29f5442e41 100644
---
a/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/javadsl/Effect.scala
+++
b/persistence-typed/src/main/scala/org/apache/pekko/persistence/typed/state/javadsl/Effect.scala
@@ -44,7 +44,12 @@ import pekko.persistence.typed.state.internal.SideEffect
*/
final def persist(state: State): EffectBuilder[State] = Persist(state)
- // FIXME add delete effect
+ /**
+ * Delete the persisted state.
+ *
+ * Side effects can be chained with `thenRun`.
+ */
+ def delete(): EffectBuilder[State] =
Delete().asInstanceOf[EffectBuilder[State]]
/**
* Do not persist anything
diff --git
a/persistence-typed/src/test/java/jdocs/org/apache/pekko/persistence/typed/DurableStatePersistentBehaviorTest.java
b/persistence-typed/src/test/java/jdocs/org/apache/pekko/persistence/typed/DurableStatePersistentBehaviorTest.java
index d30deaf6c0..278971ceca 100644
---
a/persistence-typed/src/test/java/jdocs/org/apache/pekko/persistence/typed/DurableStatePersistentBehaviorTest.java
+++
b/persistence-typed/src/test/java/jdocs/org/apache/pekko/persistence/typed/DurableStatePersistentBehaviorTest.java
@@ -105,6 +105,10 @@ public class DurableStatePersistentBehaviorTest {
}
}
+ public enum Delete implements Command<Void> {
+ INSTANCE
+ }
+
// #command
// #state
@@ -150,6 +154,7 @@ public class DurableStatePersistentBehaviorTest {
(state, command) -> Effect().persist(new State(state.get() +
command.value)))
.onCommand(
GetValue.class, (state, command) ->
Effect().reply(command.replyTo, state.get()))
+ .onCommand(Delete.class, (state, command) -> Effect().delete())
.build();
}
// #command-handler
diff --git a/persistence/src/main/resources/reference.conf
b/persistence/src/main/resources/reference.conf
index c00b1f5763..6e13d82a8c 100644
--- a/persistence/src/main/resources/reference.conf
+++ b/persistence/src/main/resources/reference.conf
@@ -266,6 +266,9 @@ pekko.persistence.journal.inmem {
# Turn this on to test serialization of the events
test-serialization = off
+
+ # Useful for tests, increase to make writes take time like an actual
persistent journal
+ delay-writes = 0s
}
# Local file system snapshot store plugin.
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 ccdb90c9eb..65d7ec5fe2 100644
--- a/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
+++ b/persistence/src/main/scala/org/apache/pekko/persistence/Eventsourced.scala
@@ -653,6 +653,7 @@ private[persistence] trait Eventsourced
}
{
+ case PersistentRepr(FilteredPayload, _)
=> // ignore
case PersistentRepr(payload, _) if recoveryRunning &&
_receiveRecover.isDefinedAt(payload) =>
_receiveRecover(payload)
case s: SnapshotOffer if _receiveRecover.isDefinedAt(s) =>
diff --git
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/inmem/InmemJournal.scala
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/inmem/InmemJournal.scala
index 235a003010..09ddde75da 100644
---
a/persistence/src/main/scala/org/apache/pekko/persistence/journal/inmem/InmemJournal.scala
+++
b/persistence/src/main/scala/org/apache/pekko/persistence/journal/inmem/InmemJournal.scala
@@ -15,6 +15,8 @@ package org.apache.pekko.persistence.journal.inmem
import scala.collection.immutable
import scala.concurrent.Future
+import scala.concurrent.duration.Duration
+import scala.jdk.DurationConverters._
import scala.util.Try
import scala.util.control.NonFatal
@@ -23,6 +25,7 @@ import pekko.actor.ActorRef
import pekko.annotation.ApiMayChange
import pekko.annotation.InternalApi
import pekko.event.Logging
+import pekko.pattern.after
import pekko.persistence.AtomicWrite
import pekko.persistence.JournalProtocol.RecoverySuccess
import pekko.persistence.PersistentRepr
@@ -77,6 +80,12 @@ object InmemJournal {
else false
}
+ private val delayWrites = {
+ val key = "delay-writes"
+ if (cfg.hasPath(key)) cfg.getDuration(key).toScala
+ else Duration.Zero
+ }
+
private val serialization = SerializationExtension(context.system)
private val eventStream = context.system.eventStream
@@ -92,7 +101,10 @@ object InmemJournal {
add(p)
eventStream.publish(InmemJournal.Write(p.payload, p.persistenceId,
p.sequenceNr))
}
- Future.successful(Nil) // all good
+ if (delayWrites.length > 0)
+ after(delayWrites, context.system.scheduler) { Future.successful(Nil)
}(context.dispatcher)
+ else
+ Future.successful(Nil) // all good
} catch {
case NonFatal(e) =>
// serialization problem
diff --git
a/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorSpec.scala
b/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorSpec.scala
index 31f5a2238d..34eae20bfc 100644
---
a/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorSpec.scala
+++
b/persistence/src/test/scala/org/apache/pekko/persistence/PersistentActorSpec.scala
@@ -127,6 +127,24 @@ object PersistentActorSpec {
extends Behavior3PersistentActor(name)
with InmemRuntimePluginConfig
+ class Behavior4PersistentActor(name: String) extends
ExamplePersistentActor(name) {
+ val receiveCommand: Receive = commonBehavior.orElse {
+ case FilteredPayload =>
+ persist(FilteredPayload)(_ => ())
+ case Cmd(data) =>
+ persist(Evt(s"$data-${lastSequenceNr + 1}"))(updateState)
+ }
+
+ override def receiveRecover: Receive = super.receiveRecover.orElse {
+ case FilteredPayload =>
+ throw new IllegalStateException("Unexpected FilteredPayload")
+ }
+ }
+
+ class Behavior4PersistentActorWithInmemRuntimePluginConfig(name: String, val
providedConfig: Config)
+ extends Behavior4PersistentActor(name)
+ with InmemRuntimePluginConfig
+
class ChangeBehaviorInLastEventHandlerPersistentActor(name: String) extends
ExamplePersistentActor(name) {
val newBehavior: Receive = {
case Cmd(data) =>
@@ -975,6 +993,8 @@ abstract class PersistentActorSpec(config: Config) extends
PersistenceSpec(confi
protected def behavior3PersistentActor: ActorRef =
namedPersistentActor[Behavior3PersistentActor]
+ protected def behavior4PersistentActor: ActorRef =
namedPersistentActor[Behavior4PersistentActor]
+
protected def changeBehaviorInFirstEventHandlerPersistentActor: ActorRef =
namedPersistentActor[ChangeBehaviorInFirstEventHandlerPersistentActor]
@@ -1137,6 +1157,17 @@ abstract class PersistentActorSpec(config: Config)
extends PersistenceSpec(confi
persistentActor ! GetState
expectMsg(List("a-1", "a-2", "b-10", "b-11", "b-12", "c-10", "c-11",
"c-12"))
}
+ "exclude FilteredEvent in replay of persisted events" in {
+ val persistentActor = behavior4PersistentActor
+ persistentActor ! GetState
+ expectMsg(List("a-1", "a-2"))
+ persistentActor ! FilteredPayload
+ persistentActor ! Cmd("b")
+ persistentActor ! "boom"
+ persistentActor ! Cmd("c")
+ persistentActor ! GetState
+ expectMsg(List("a-1", "a-2", "b-4", "c-5")) // seqNr 3 was for
FilteredPayload
+ }
"recover on command failure" in {
val persistentActor = behavior3PersistentActor
persistentActor ! Cmd("b")
@@ -1693,6 +1724,9 @@ class InmemPersistentActorWithRuntimePluginConfigSpec
override protected def behavior3PersistentActor: ActorRef =
namedPersistentActorWithProvidedConfig[Behavior3PersistentActorWithInmemRuntimePluginConfig](providedActorConfig)
+ override protected def behavior4PersistentActor: ActorRef =
+
namedPersistentActorWithProvidedConfig[Behavior4PersistentActorWithInmemRuntimePluginConfig](providedActorConfig)
+
override protected def changeBehaviorInFirstEventHandlerPersistentActor:
ActorRef =
namedPersistentActorWithProvidedConfig[
ChangeBehaviorInFirstEventHandlerPersistentActorWithInmemRuntimePluginConfig](providedActorConfig)
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]