From 08e5725995e0dea7c3e2a11f6b6d724bfeae881f Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 7 Nov 2023 18:55:12 +0100 Subject: [PATCH 01/29] implement akka-free (persistence) recovery API --- build.sbt | 14 +- .../eventsopircing/persistence/Event.scala | 8 + .../persistence/EventSourcedStore.scala | 41 +++++ .../eventsopircing/persistence/Recovery.scala | 18 ++ .../eventsopircing/persistence/Snapshot.scala | 16 ++ .../eventsopircing/persistence/package.scala | 7 + .../persistence/EventSourcedStoreOf.scala | 167 ++++++++++++++++++ project/Dependencies.scala | 1 + 8 files changed, 269 insertions(+), 3 deletions(-) create mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala create mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala create mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala create mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala create mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala diff --git a/build.sbt b/build.sbt index 031d1f5c..9aa0d5b7 100644 --- a/build.sbt +++ b/build.sbt @@ -28,6 +28,7 @@ lazy val root = (project in file(".") testkit, persistence, eventsourcing, + `eventsourcing-persistence`, cluster, `cluster-sharding`)) @@ -64,13 +65,20 @@ lazy val testkit = (project in file("testkit") Akka.testkit % Test, scalatest % Test))) +lazy val `eventsourcing-persistence` = (project in file("eventsourcing-persistence") + settings (name := "akka-effect-eventsourcing-persistence") + settings commonSettings + settings ( + libraryDependencies ++= Seq(sstream))) + lazy val persistence = (project in file("persistence") settings (name := "akka-effect-persistence") settings commonSettings dependsOn( - actor % "test->test;compile->compile", - testkit % "test->test;test->compile", - `actor-tests` % "test->test") + `eventsourcing-persistence` % "test->test;compile->compile", + actor % "test->test;compile->compile", + testkit % "test->test;test->compile", + `actor-tests` % "test->test") settings ( libraryDependencies ++= Seq( Akka.actor, diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala new file mode 100644 index 00000000..fe066a1d --- /dev/null +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala @@ -0,0 +1,8 @@ +package com.evolution.akkaeffect.eventsopircing.persistence + +trait Event[E] { + + def event: E + def seqNr: SeqNr + +} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala new file mode 100644 index 00000000..b145f6fb --- /dev/null +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala @@ -0,0 +1,41 @@ +package com.evolution.akkaeffect.eventsopircing.persistence + +/** + * Event sourcing persistence API: provides snapshot followed by stream of events + * @tparam F effect + * @tparam S snapshot + * @tparam E event + */ +trait EventSourcedStore[F[_], S, E] { + + import EventSourcedStore._ + + /** + * Start recovery by retrieving snapshot (eager, happening on outer F) + * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) + * @param id persistent ID + * @param criteria snapshot lookup criteria + * @return + */ + def recover(id: Id, criteria: Criteria): F[Recovery[F, S, E]] + +} + +object EventSourcedStore { + + /** ID of persistent actor + * @see [[com.evolutiongaming.akkaeffect.persistence.EventSourcedId]] + * @see [[akka.persistence.PersistentActor.persistenceId]] + */ + final case class Id(value: String) extends AnyVal + + /** + * Snapshot lookup criteria + * @see [[akka.persistence.SnapshotSelectionCriteria]] + */ + final case class Criteria(maxSequenceNr: Long = Long.MaxValue, + maxTimestamp: Long = Long.MaxValue, + minSequenceNr: Long = 0L, + minTimestamp: Long = 0L) + +} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala new file mode 100644 index 00000000..f3ee00fd --- /dev/null +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala @@ -0,0 +1,18 @@ +package com.evolution.akkaeffect.eventsopircing.persistence + +import com.evolutiongaming.sstream.Stream + +/** + * Representation of _started_ recovery process: + * snapshot is already loaded in memory (if any) + * while events will be loaded only on materialisation of [[Stream]] + * @tparam F effect + * @tparam S snapshot + * @tparam E event + */ +trait Recovery[F[_], S, E] { + + def snapshot: Option[Snapshot[S]] + def events: Stream[F, Event[E]] + +} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala new file mode 100644 index 00000000..2ef825f3 --- /dev/null +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala @@ -0,0 +1,16 @@ +package com.evolution.akkaeffect.eventsopircing.persistence + +import java.time.Instant + +trait Snapshot[S] { + + def snapshot: S + def metadata: Snapshot.Metadata + +} + +object Snapshot { + + final case class Metadata(seqNr: SeqNr, timestamp: Instant) + +} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala new file mode 100644 index 00000000..7439bc6a --- /dev/null +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala @@ -0,0 +1,7 @@ +package com.evolution.akkaeffect.eventsopircing + +package object persistence { + + type SeqNr = Long + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala new file mode 100644 index 00000000..9e3b6ecb --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -0,0 +1,167 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.persistence.SnapshotSelectionCriteria +import cats.syntax.all._ +import cats.effect.{Async, Ref, Resource, Sync} +import akka.persistence.journal.AsyncRecovery +import akka.persistence.snapshot.SnapshotStore +import com.evolution.akkaeffect.eventsopircing.persistence.{ + Event, + EventSourcedStore, + Recovery, + Snapshot +} +import com.evolutiongaming.catshelper.FromFuture +import com.evolutiongaming.catshelper.ToTry +import com.evolutiongaming.sstream.Stream + +import java.time.Instant +import scala.concurrent.Future +import scala.util.Try +import com.evolutiongaming.sstream.FoldWhile._ + +object EventSourcedStoreOf { + + /** + * [[EventSourcedStore]] implementation based on Akka Persistence API. + * + * The implementation delegates snapshot and events load to [[SnapshotStore]] and [[AsyncRecovery]]. + * Snapshot loaded on [[EventSourcedStore#recover]] F while events loaded lazily: + * first events will be available for [[Stream#foldWhileM]] while tail still loaded by [[AsyncRecovery]] + * + * @param snapshotStore Akka Persistence snapshot (plugin) + * @param journal Akka Persistence journal (plugin) + * @tparam F effect + * @tparam S snapshot + * @tparam E event + * @return resource of [[EventSourcedStore]] + */ + def fromAkka[F[_]: Async: ToTry, S, E](snapshotStore: SnapshotStore, + journal: AsyncRecovery, + ): Resource[F, EventSourcedStore[F, S, E]] = { + + val eventSourcedStore = new EventSourcedStore[F, S, E] { + + override def recover( + id: EventSourcedStore.Id, + criteria: EventSourcedStore.Criteria + ): F[Recovery[F, S, E]] = { + + val snapshotSelectionCriteria = SnapshotSelectionCriteria( + criteria.maxSequenceNr, + criteria.maxTimestamp, + criteria.minSequenceNr, + criteria.minTimestamp + ) + + snapshotStore + .loadAsync(id.value, snapshotSelectionCriteria) + .liftTo[F] + .map { offer => + new Recovery[F, S, E] { + + override val snapshot: Option[Snapshot[S]] = + offer.map { offer => + new Snapshot[S] { + override def snapshot: S = offer.snapshot.asInstanceOf[S] + + override def metadata: Snapshot.Metadata = + Snapshot.Metadata( + seqNr = offer.metadata.sequenceNr, + timestamp = + Instant.ofEpochMilli(offer.metadata.timestamp) + ) + } + } + + override val events: Stream[F, Event[E]] = { + val fromSequenceNr = + snapshot.map(_.metadata.seqNr).getOrElse(0L) + + val stream = for { + + buffer <- Ref[F].of(Vector.empty[Event[E]]) + + highestSequenceNr <- journal + .asyncReadHighestSequenceNr(id.value, fromSequenceNr) + .liftTo[F] + + replayed <- Sync[F].delay { + + journal.asyncReplayMessages( + id.value, + fromSequenceNr, + highestSequenceNr, + Long.MaxValue + ) { persisted => + if (persisted.deleted) {} else { + val event = new Event[E] { + override val event: E = + persisted.payload.asInstanceOf[E] + override val seqNr: SeqNr = + persisted.sequenceNr + } + val _ = buffer.update(_.appended(event)).toTry + } + } + + } + } yield { + + new Stream[F, Event[E]] { + + override def foldWhileM[L, R]( + l: L + )(f: (L, Event[E]) => F[Either[L, R]]): F[Either[L, R]] = { + + l.asLeft[R] + .tailRecM { + case Left(l) => + for { + events <- buffer.getAndSet(Vector.empty[Event[E]]) + result <- events.foldWhileM(l)(f) + result <- result match { + + case l: Left[L, R] => + for { + replayed <- Sync[F].delay( + replayed.isCompleted + ) + } yield + if (replayed) l.asRight[Either[L, R]] + else l.asLeft[Either[L, R]] + + case result => + result.asRight[Either[L, R]].pure[F] + + } + } yield result + + case result => result.asRight[Either[L, R]].pure[F] + } + } + + } + } + + Stream.lift(stream).flatten + } + } + } + } + + } + + eventSourcedStore.pure[Resource[F, *]] + + } + + implicit class FromFutureSyntax[A](val future: Future[A]) extends AnyVal { + def liftTo[F[_]: FromFuture]: F[A] = FromFuture[F].apply(future) + } + + implicit class ToTrySyntax[F[_], A](val fa: F[A]) extends AnyVal { + def toTry(implicit F: ToTry[F]): Try[A] = F(fa) + } + +} diff --git a/project/Dependencies.scala b/project/Dependencies.scala index d0dee9ce..2646d3ab 100644 --- a/project/Dependencies.scala +++ b/project/Dependencies.scala @@ -10,6 +10,7 @@ object Dependencies { val `kind-projector` = "org.typelevel" % "kind-projector" % "0.13.2" val pureconfig = "com.github.pureconfig" %% "pureconfig" % "0.17.3" val smetrics = "com.evolutiongaming" %% "smetrics" % "2.0.0" + val sstream = "com.evolutiongaming" %% "sstream" % "1.0.1" object Cats { private val version = "2.9.0" From 23a117742d034cc7f405574e68b6addf51b3ca51 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Wed, 8 Nov 2023 11:44:57 +0100 Subject: [PATCH 02/29] fix 2.12 compile err --- .../eventsopircing/persistence/EventSourcedStore.scala | 2 +- .../akkaeffect/eventsopircing/persistence/Recovery.scala | 2 +- .../akkaeffect/persistence/EventSourcedStoreOf.scala | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala index b145f6fb..4d90a722 100644 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala @@ -15,7 +15,7 @@ trait EventSourcedStore[F[_], S, E] { * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) * @param id persistent ID * @param criteria snapshot lookup criteria - * @return + * @return [[Recovery]] instance, representing __started__ recovery */ def recover(id: Id, criteria: Criteria): F[Recovery[F, S, E]] diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala index f3ee00fd..a27e2cc1 100644 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala @@ -3,7 +3,7 @@ package com.evolution.akkaeffect.eventsopircing.persistence import com.evolutiongaming.sstream.Stream /** - * Representation of _started_ recovery process: + * Representation of __started__ recovery process: * snapshot is already loaded in memory (if any) * while events will be loaded only on materialisation of [[Stream]] * @tparam F effect diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 9e3b6ecb..fe23ff10 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -101,7 +101,7 @@ object EventSourcedStoreOf { override val seqNr: SeqNr = persisted.sequenceNr } - val _ = buffer.update(_.appended(event)).toTry + val _ = buffer.update(_ :+ event).toTry } } From 3d8795e6bc3ba95e384c6987db4eb3a3b329f2da Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 14 Nov 2023 12:44:31 +0100 Subject: [PATCH 03/29] drop snapshot criteria from new ESS API --- .../persistence/EventSourcedStore.scala | 3 +- .../persistence/EventSourcedStoreOf.scala | 30 +++++-------------- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala index 4d90a722..3f3a13fa 100644 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala @@ -14,10 +14,9 @@ trait EventSourcedStore[F[_], S, E] { * Start recovery by retrieving snapshot (eager, happening on outer F) * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) * @param id persistent ID - * @param criteria snapshot lookup criteria * @return [[Recovery]] instance, representing __started__ recovery */ - def recover(id: Id, criteria: Criteria): F[Recovery[F, S, E]] + def recover(id: Id): F[Recovery[F, S, E]] } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index fe23ff10..89d70d29 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -1,24 +1,18 @@ package com.evolutiongaming.akkaeffect.persistence import akka.persistence.SnapshotSelectionCriteria -import cats.syntax.all._ -import cats.effect.{Async, Ref, Resource, Sync} import akka.persistence.journal.AsyncRecovery import akka.persistence.snapshot.SnapshotStore -import com.evolution.akkaeffect.eventsopircing.persistence.{ - Event, - EventSourcedStore, - Recovery, - Snapshot -} -import com.evolutiongaming.catshelper.FromFuture -import com.evolutiongaming.catshelper.ToTry +import cats.effect.{Async, Ref, Resource, Sync} +import cats.syntax.all._ +import com.evolution.akkaeffect.eventsopircing.persistence.{Event, EventSourcedStore, Recovery, Snapshot} +import com.evolutiongaming.catshelper.{FromFuture, ToTry} +import com.evolutiongaming.sstream.FoldWhile._ import com.evolutiongaming.sstream.Stream import java.time.Instant import scala.concurrent.Future import scala.util.Try -import com.evolutiongaming.sstream.FoldWhile._ object EventSourcedStoreOf { @@ -42,20 +36,10 @@ object EventSourcedStoreOf { val eventSourcedStore = new EventSourcedStore[F, S, E] { - override def recover( - id: EventSourcedStore.Id, - criteria: EventSourcedStore.Criteria - ): F[Recovery[F, S, E]] = { - - val snapshotSelectionCriteria = SnapshotSelectionCriteria( - criteria.maxSequenceNr, - criteria.maxTimestamp, - criteria.minSequenceNr, - criteria.minTimestamp - ) + override def recover(id: EventSourcedStore.Id): F[Recovery[F, S, E]] = { snapshotStore - .loadAsync(id.value, snapshotSelectionCriteria) + .loadAsync(id.value, SnapshotSelectionCriteria()) .liftTo[F] .map { offer => new Recovery[F, S, E] { From a3140b3a88aa73b70e65d9021a969b001c243e9a Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 14 Nov 2023 16:48:49 +0100 Subject: [PATCH 04/29] drop snapshot criteria --- .../eventsopircing/persistence/EventSourcedStore.scala | 9 --------- 1 file changed, 9 deletions(-) diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala index 3f3a13fa..96fea0b8 100644 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala +++ b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala @@ -28,13 +28,4 @@ object EventSourcedStore { */ final case class Id(value: String) extends AnyVal - /** - * Snapshot lookup criteria - * @see [[akka.persistence.SnapshotSelectionCriteria]] - */ - final case class Criteria(maxSequenceNr: Long = Long.MaxValue, - maxTimestamp: Long = Long.MaxValue, - minSequenceNr: Long = 0L, - minTimestamp: Long = 0L) - } From b7ae06e275a096ab9001cd1eaa2b224bce2abb4e Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 21 Nov 2023 15:41:37 +0100 Subject: [PATCH 05/29] wip: implement event-sourced (persistent) actor --- .../com/evolutiongaming/akkaeffect/Act.scala | 10 +- .../evolutiongaming/akkaeffect/ActorOf.scala | 5 +- .../evolutiongaming/akkaeffect/ActorVar.scala | 4 +- build.sbt | 28 ++- .../eventsopircing/persistence/Event.scala | 8 - .../persistence/EventSourcedStore.scala | 31 --- .../eventsopircing/persistence/package.scala | 7 - .../eventsourcing/JournalKeeper.scala | 7 + .../eventsourcing/JournalKeeperTest.scala | 7 + .../akkaeffect/persistence/Append.scala | 71 ++++++ .../persistence/DeleteEventsTo.scala | 50 ++-- .../akkaeffect/persistence/Event.scala | 8 + .../persistence/EventSourcedId.scala | 7 +- .../persistence/EventSourcedStore.scala | 25 ++ .../akkaeffect/persistence/Events.scala | 161 +++++++++++++ .../akkaeffect/persistence/Journaller.scala | 37 +-- .../akkaeffect}/persistence/Recovery.scala | 2 +- .../akkaeffect}/persistence/Snapshot.scala | 2 +- .../akkaeffect/persistence/Snapshotter.scala | 222 ++++++++++++++++++ .../akkaeffect/persistence/package.scala | 5 - .../akka/persistence/SnapshotterInterop.scala | 5 +- .../{Append.scala => AppendOf.scala} | 75 +----- .../persistence/DeleteEventsToOf.scala | 21 ++ .../akkaeffect/persistence/EventSourced.scala | 2 + .../persistence/EventSourcedActorOf.scala | 80 +++++++ .../persistence/EventSourcedStoreOf.scala | 123 +++++++++- .../akkaeffect/persistence/Events.scala | 144 ------------ .../persistence/PersistentActorOf.scala | 104 +++++--- .../akkaeffect/persistence/Snapshotter.scala | 164 ------------- .../persistence/SnapshotterOf.scala | 18 ++ .../akkaeffect/persistence/AppendTest.scala | 6 +- .../persistence/InstrumentEventSourced.scala | 14 ++ .../persistence/SnapshotterTest.scala | 2 +- 33 files changed, 903 insertions(+), 552 deletions(-) delete mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala delete mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala delete mode 100644 eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala create mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala rename {persistence => persistence-api}/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala (60%) create mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala rename {persistence => persistence-api}/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala (88%) create mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala create mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala rename {persistence => persistence-api}/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala (74%) rename {eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing => persistence-api/src/main/scala/com/evolutiongaming/akkaeffect}/persistence/Recovery.scala (86%) rename {eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing => persistence-api/src/main/scala/com/evolutiongaming/akkaeffect}/persistence/Snapshot.scala (75%) create mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala rename {persistence => persistence-api}/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala (63%) rename persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/{Append.scala => AppendOf.scala} (69%) create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsToOf.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala delete mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala delete mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterOf.scala diff --git a/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala b/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala index 093b9bf5..881789a2 100644 --- a/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala +++ b/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala @@ -106,7 +106,15 @@ private[akkaeffect] object Act { } } - def receive(receive: Actor.Receive) = { + /** + * set thread local [[threadLocal]] to [[self]] and + * if message is of type [[Msg]] - apply internal function, + * otherwise delegate receive to [[receive]] + * + * @param receive [[Actor.Receive]] partial function + * @return [[Actor.Receive]] partial function: Any => Unit + */ + def receive(receive: Actor.Receive): Actor.Receive = { val receiveMsg: Actor.Receive = { case Msg(f) => f() } syncReceive(receiveMsg orElse receive) } diff --git a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala index 7309e1f2..2efa1969 100644 --- a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala +++ b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala @@ -23,14 +23,15 @@ object ActorOf { type State = Receive[F, Envelope[Any], Stop] - def onPreStart(actorCtx: ActorCtx[F])(implicit fail: Fail[F]) = { + def onPreStart(actorCtx: ActorCtx[F])(implicit fail: Fail[F]): Resource[F, Receive[F, Envelope[Any], Stop]] = { receiveOf(actorCtx) .handleErrorWith { (error: Throwable) => s"failed to allocate receive".fail[F, State](error).toResource } } - def onReceive(a: Any, sender: ActorRef)(implicit fail: Fail[F]) = { + // a - message + def onReceive(a: Any, sender: ActorRef)(implicit fail: Fail[F]): State => F[Directive[Releasable[F, State]]] = { state: State => val stop = a match { case ReceiveTimeout => state.timeout diff --git a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala index 94457d6d..0d48ab6f 100644 --- a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala +++ b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala @@ -73,6 +73,8 @@ private[akkaeffect] object ActorVar { new ActorVar[F, A] { + // A - actor' state + def preStart(resource: Resource[F, A]) = { update { _ => resource @@ -81,7 +83,7 @@ private[akkaeffect] object ActorVar { } } - def receive(f: A => F[Directive[Releasable[F, A]]]) = { + def receive(f: A => F[Directive[Releasable[F, A]]]): Unit = { update { case Some(state) => f(state.value).flatMap { diff --git a/build.sbt b/build.sbt index 9aa0d5b7..d6285d2d 100644 --- a/build.sbt +++ b/build.sbt @@ -27,8 +27,8 @@ lazy val root = (project in file(".") `actor-tests`, testkit, persistence, + `persistence-api`, eventsourcing, - `eventsourcing-persistence`, cluster, `cluster-sharding`)) @@ -65,20 +65,32 @@ lazy val testkit = (project in file("testkit") Akka.testkit % Test, scalatest % Test))) -lazy val `eventsourcing-persistence` = (project in file("eventsourcing-persistence") - settings (name := "akka-effect-eventsourcing-persistence") +lazy val `persistence-api` = (project in file("persistence-api") + settings (name := "akka-effect-persistence-api") settings commonSettings + dependsOn( + actor % "test->test;compile->compile", + testkit % "test->test;test->compile", + `actor-tests` % "test->test") settings ( - libraryDependencies ++= Seq(sstream))) + libraryDependencies ++= Seq( + Cats.core, + CatsEffect.effect, + `cats-helper`, + sstream, + Akka.persistence, // temporal dependency + Akka.slf4j % Test, + Akka.testkit % Test, + scalatest % Test))) lazy val persistence = (project in file("persistence") settings (name := "akka-effect-persistence") settings commonSettings dependsOn( - `eventsourcing-persistence` % "test->test;compile->compile", - actor % "test->test;compile->compile", - testkit % "test->test;test->compile", - `actor-tests` % "test->test") + `persistence-api` % "test->test;compile->compile", + actor % "test->test;compile->compile", + testkit % "test->test;test->compile", + `actor-tests` % "test->test") settings ( libraryDependencies ++= Seq( Akka.actor, diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala deleted file mode 100644 index fe066a1d..00000000 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Event.scala +++ /dev/null @@ -1,8 +0,0 @@ -package com.evolution.akkaeffect.eventsopircing.persistence - -trait Event[E] { - - def event: E - def seqNr: SeqNr - -} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala deleted file mode 100644 index 96fea0b8..00000000 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/EventSourcedStore.scala +++ /dev/null @@ -1,31 +0,0 @@ -package com.evolution.akkaeffect.eventsopircing.persistence - -/** - * Event sourcing persistence API: provides snapshot followed by stream of events - * @tparam F effect - * @tparam S snapshot - * @tparam E event - */ -trait EventSourcedStore[F[_], S, E] { - - import EventSourcedStore._ - - /** - * Start recovery by retrieving snapshot (eager, happening on outer F) - * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) - * @param id persistent ID - * @return [[Recovery]] instance, representing __started__ recovery - */ - def recover(id: Id): F[Recovery[F, S, E]] - -} - -object EventSourcedStore { - - /** ID of persistent actor - * @see [[com.evolutiongaming.akkaeffect.persistence.EventSourcedId]] - * @see [[akka.persistence.PersistentActor.persistenceId]] - */ - final case class Id(value: String) extends AnyVal - -} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala b/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala deleted file mode 100644 index 7439bc6a..00000000 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/package.scala +++ /dev/null @@ -1,7 +0,0 @@ -package com.evolution.akkaeffect.eventsopircing - -package object persistence { - - type SeqNr = Long - -} diff --git a/eventsourcing/src/main/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeper.scala b/eventsourcing/src/main/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeper.scala index 8d12a81a..32083aa2 100644 --- a/eventsourcing/src/main/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeper.scala +++ b/eventsourcing/src/main/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeper.scala @@ -328,6 +328,12 @@ object JournalKeeper { def delete(criteria: SnapshotSelectionCriteria) = { + delete(Snapshotter.Criteria(criteria)) + + } + + def delete(criteria: Snapshotter.Criteria): F[F[Unit]] = { + def selected(meta: SnapshotMetadata) = { meta.seqNr <= criteria.maxSequenceNr && meta.timestamp.toEpochMilli <= criteria.maxSequenceNr } @@ -346,6 +352,7 @@ object JournalKeeper { .map { _.joinWithNever } } } + } } } diff --git a/eventsourcing/src/test/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeperTest.scala b/eventsourcing/src/test/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeperTest.scala index 515b2a7a..12e88ea0 100644 --- a/eventsourcing/src/test/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeperTest.scala +++ b/eventsourcing/src/test/scala/com/evolutiongaming/akkaeffect/eventsourcing/JournalKeeperTest.scala @@ -510,6 +510,13 @@ object JournalKeeperTest { .add(Action.DeleteSnapshots(criteria)) .map { _.pure[F] } } + + def delete(criteria: Snapshotter.Criteria): F[F[Unit]] = { + actions + .add(Action.DeleteSnapshots(criteria.asAkka)) + .map { _.pure[F] } + } + } diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala new file mode 100644 index 00000000..566cb55e --- /dev/null +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala @@ -0,0 +1,71 @@ +package com.evolutiongaming.akkaeffect.persistence + +import cats.implicits._ +import cats.{Applicative, FlatMap, Monad, ~>} +import com.evolutiongaming.akkaeffect.Fail +import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable} + +trait Append[F[_], -A] { + + /** + * @param events to be saved, inner Nel[A] will be persisted atomically, outer Nel[_] is for batching + * @return SeqNr of last event + */ + def apply(events: Events[A]): F[F[SeqNr]] +} + +object Append { + + def const[F[_], A](seqNr: F[F[SeqNr]]): Append[F, A] = { + class Const + new Const with Append[F, A] { + def apply(events: Events[A]) = seqNr + } + } + + def empty[F[_]: Applicative, A]: Append[F, A] = + const(SeqNr.Min.pure[F].pure[F]) + + implicit class AppendOps[F[_], A](val self: Append[F, A]) extends AnyVal { + + def mapK[G[_]: Applicative](f: F ~> G): Append[G, A] = { events => + f(self(events)).map { a => + f(a) + } + } + + def convert[B](f: B => F[A])(implicit F: Monad[F]): Append[F, B] = { + events => + { + for { + events <- events.traverse(f) + seqNr <- self(events) + } yield seqNr + } + } + + def narrow[B <: A]: Append[F, B] = events => self(events) + + def withLogging1(log: Log[F])( + implicit + F: FlatMap[F], + measureDuration: MeasureDuration[F] + ): Append[F, A] = events => { + for { + d <- MeasureDuration[F].start + r <- self(events) + } yield + for { + r <- r + d <- d + _ <- log.debug(s"append ${events.size} events in ${d.toMillis}ms") + } yield r + } + + def withFail(fail: Fail[F])(implicit F: MonadThrowable[F]): Append[F, A] = { + events => + fail.adapt(s"failed to append $events") { self(events) } + } + } + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala similarity index 60% rename from persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala rename to persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala index d8962cb9..bd02889c 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala @@ -1,17 +1,10 @@ package com.evolutiongaming.akkaeffect.persistence -import akka.persistence.DeleteEventsToInterop -import cats.effect.{Resource, Sync} import cats.syntax.all._ import cats.{Applicative, FlatMap, ~>} import com.evolutiongaming.akkaeffect.Fail -import com.evolutiongaming.catshelper.{FromFuture, Log, MeasureDuration, MonadThrowable} +import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable} -import scala.concurrent.duration.FiniteDuration - -/** - * @see [[akka.persistence.Eventsourced.deleteMessages]] - */ trait DeleteEventsTo[F[_]] { /** @@ -31,32 +24,25 @@ object DeleteEventsTo { } } - - private[akkaeffect] def of[F[_]: Sync: FromFuture, A]( - persistentActor: akka.persistence.PersistentActor, - timeout: FiniteDuration - ): Resource[F, DeleteEventsTo[F]] = { - DeleteEventsToInterop(persistentActor, timeout) - } - - private sealed abstract class WithLogging private sealed abstract class WithFail private sealed abstract class MapK - - implicit class DeleteEventsToOps[F[_]](val self: DeleteEventsTo[F]) extends AnyVal { + implicit class DeleteEventsToOps[F[_]](val self: DeleteEventsTo[F]) + extends AnyVal { def mapK[G[_]: Applicative](f: F ~> G): DeleteEventsTo[G] = { new MapK with DeleteEventsTo[G] { - def apply(seqNr: SeqNr) = f(self(seqNr)).map { a => f(a) } + def apply(seqNr: SeqNr) = f(self(seqNr)).map { a => + f(a) + } } } - def withLogging1( - log: Log[F])(implicit + def withLogging1(log: Log[F])( + implicit F: FlatMap[F], measureDuration: MeasureDuration[F] ): DeleteEventsTo[F] = { @@ -65,19 +51,18 @@ object DeleteEventsTo { for { d <- MeasureDuration[F].start r <- self(seqNr) - } yield for { - r <- r - d <- d - _ <- log.info(s"delete events to $seqNr in ${ d.toMillis }ms") - } yield r + } yield + for { + r <- r + d <- d + _ <- log.info(s"delete events to $seqNr in ${d.toMillis}ms") + } yield r } } } - def withFail( - fail: Fail[F])(implicit - F: MonadThrowable[F] - ): DeleteEventsTo[F] = { + def withFail(fail: Fail[F])(implicit + F: MonadThrowable[F]): DeleteEventsTo[F] = { new WithFail with DeleteEventsTo[F] { def apply(seqNr: SeqNr) = { @@ -88,4 +73,5 @@ object DeleteEventsTo { } } } -} \ No newline at end of file + +} diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala new file mode 100644 index 00000000..7c11b79d --- /dev/null +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala @@ -0,0 +1,8 @@ +package com.evolutiongaming.akkaeffect.persistence + +trait Event[E] { + + def event: E + def seqNr: SeqNr + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala similarity index 88% rename from persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala rename to persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala index 7b096ab4..ef7d18ca 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedId.scala @@ -12,7 +12,10 @@ final case class EventSourcedId(value: String) { object EventSourcedId { - implicit val orderEventSourcedId: Order[EventSourcedId] = Order.by { a: EventSourcedId => a.value } + implicit val orderEventSourcedId: Order[EventSourcedId] = Order.by { + a: EventSourcedId => + a.value + } implicit val showEventSourcedId: Show[EventSourcedId] = Show.fromToString -} \ No newline at end of file +} diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala new file mode 100644 index 00000000..91598f70 --- /dev/null +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -0,0 +1,25 @@ +package com.evolutiongaming.akkaeffect.persistence + +import cats.effect.kernel.Resource + +/** + * Event sourcing persistence API: provides snapshot followed by stream of events + * + * @tparam F effect + * @tparam S snapshot + * @tparam E event + */ +trait EventSourcedStore[F[_], S, E] { + + /** + * Start recovery by retrieving snapshot (eager, happening on outer F) + * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) + * @param id persistent ID + * @return [[Recovery]] represents started recovery, resource will be released upon actor termination + */ + def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] + + def journaller(id: EventSourcedId): Resource[F, Journaller[F, E]] + + def snapshotter(id: EventSourcedId): Resource[F, Snapshotter[F, S]] +} diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala new file mode 100644 index 00000000..1df89bf3 --- /dev/null +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala @@ -0,0 +1,161 @@ +package com.evolutiongaming.akkaeffect.persistence + +import cats.data.{NonEmptyList => Nel} +import cats.kernel.{Eq, Semigroup} +import cats.syntax.all._ +import cats.{Apply, Eval, NonEmptyTraverse, Order, Show} + +/** + * + * @param values inner Nel[A] will be persisted atomically, + * This applies some restrictions on persistence layer. + * Please do not overuse this feature + * @tparam A event + */ +final case class Events[+A](values: Nel[Nel[A]]) { self => + + override def toString = { + Events + .show(Show.fromToString[A]) + .show(self) + } + + def map[A1](f: A => A1): Events[A1] = Events(values.map(_.map(f))) +} + +object Events { + + implicit val traverseEvents: NonEmptyTraverse[Events] = + new NonEmptyTraverse[Events] { + + def nonEmptyTraverse[G[_], A, B]( + fa: Events[A] + )(f: A => G[B])(implicit G: Apply[G]) = { + fa.values + .nonEmptyTraverse { _.nonEmptyTraverse(f) } + .map { a => + Events(a) + } + } + + def reduceLeftTo[A, B](fa: Events[A])(f: A => B)(g: (B, A) => B) = { + fa.values + .reduceLeftTo(_.reduceLeftTo(f)(g))((b, as) => as.foldLeft(b)(g)) + } + + def reduceRightTo[A, B]( + fa: Events[A] + )(f: A => B)(g: (A, Eval[B]) => Eval[B]) = { + fa.values + .reduceRightTo(_.reduceRightTo(f)(g))( + (as, b) => b.map(b => as.foldRight(b)(g)) + ) + .flatten + } + + def foldLeft[A, B](fa: Events[A], b: B)(f: (B, A) => B) = { + fa.values + .foldLeft(b) { (b, as) => + as.foldLeft(b)(f) + } + } + + def foldRight[A, B](fa: Events[A], + b: Eval[B])(f: (A, Eval[B]) => Eval[B]) = { + fa.values + .foldRight(b) { (as, b) => + as.foldRight(b)(f) + } + } + } + + implicit def semigroupEvents[A]: Semigroup[Events[A]] = { (a, b) => + Events(a.values.combine(b.values)) + } + + implicit def orderEvents[A](implicit A: Order[A]): Order[Events[A]] = + Order.by { _.values } + + implicit def show[A: Show]: Show[Events[A]] = { events => + val str = events.values match { + case Nel(events, Nil) => events.mkString_(",") + case events => + events.map { _.toList.mkString(",") }.mkString_("[", "],[", "]") + } + s"${events.productPrefix}($str)" + } + + implicit def eqEvents[A: Eq]: Eq[Events[A]] = Eq.by { _.values } + + /** + * @return single batch of attached events + */ + def of[A](a: A, as: A*): Events[A] = attached(a, as: _*) + + /** + * @return single batch of attached events + */ + def fromList[A](as: List[A]): Option[Events[A]] = as.toNel.map { as => + Events(Nel.of(as)) + } + + def batched[A](a: Nel[A], as: Nel[A]*): Events[A] = Events(Nel(a, as.toList)) + + /** + * @return single batch of attached events + */ + def attached[A](a: A, as: A*): Events[A] = Events(Nel.of(Nel(a, as.toList))) + + /** + * @return detached batches of single event + */ + def detached[A](a: A, as: A*): Events[A] = + Events(Nel(a, as.toList).map { a => + Nel.of(a) + }) + + implicit class EventsOps[A](val self: Events[A]) extends AnyVal { + + def prepend[B >: A](events: Nel[B]): Events[B] = + self.copy(values = events :: self.values) + + def prepend[B >: A](event: B): Events[B] = self.prepend(Nel.of(event)) + + def prepend[B >: A](events: List[B]): Events[B] = + events.toNel.fold[Events[B]](self) { as => + self.prepend(as) + } + + def append[B >: A](events: Nel[B]): Events[B] = + self.copy(values = self.values :+ events) + + def append[B >: A](event: B): Events[B] = self.append(Nel.of(event)) + + def append[B >: A](events: List[B]): Events[B] = + events.toNel.fold[Events[B]](self) { as => + self.append(as) + } + } + + object implicits { + + implicit class ListOpsEvents[E](val self: List[E]) extends AnyVal { + + def toEvents: Option[Events[E]] = Events.fromList(self) + + def toEventsDetached: Option[Events[E]] = self.toNel.map { as => + Events(as.map(a => Nel.of(a))) + } + } + + implicit class NelOpsEvents[E](val self: Nel[E]) extends AnyVal { + + def toEvents: Events[E] = Events.batched(self) + + def toEventsDetached: Events[E] = + Events(self.map { a => + Nel.of(a) + }) + } + } +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala similarity index 74% rename from persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala rename to persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala index a6192237..98cbbaad 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala @@ -11,30 +11,19 @@ import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable} */ trait Journaller[F[_], -A] { - /** - * @see [[akka.persistence.PersistentActor.persistAllAsync]] - */ def append: Append[F, A] - /** - * @see [[akka.persistence.Eventsourced.deleteMessages]] - * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed - */ def deleteTo: DeleteEventsTo[F] } - object Journaller { def empty[F[_]: Applicative, A]: Journaller[F, A] = { Journaller(Append.empty[F, A], DeleteEventsTo.empty[F]) } - - def apply[F[_], A]( - append: Append[F, A], - deleteEventsTo: DeleteEventsTo[F] - ): Journaller[F, A] = { + def apply[F[_], A](append: Append[F, A], + deleteEventsTo: DeleteEventsTo[F]): Journaller[F, A] = { val append1 = append @@ -47,7 +36,6 @@ object Journaller { } } - private sealed abstract class Narrow private sealed abstract class Convert @@ -56,8 +44,8 @@ object Journaller { private sealed abstract class WithFail - - implicit class JournallerOps[F[_], A](val self: Journaller[F, A]) extends AnyVal { + implicit class JournallerOps[F[_], A](val self: Journaller[F, A]) + extends AnyVal { def mapK[G[_]: Applicative](f: F ~> G): Journaller[G, A] = { new MapK with Journaller[G, A] { @@ -68,7 +56,6 @@ object Journaller { } } - def convert[B](f: B => F[A])(implicit F: Monad[F]): Journaller[F, B] = { new Convert with Journaller[F, B] { @@ -78,7 +65,6 @@ object Journaller { } } - def narrow[B <: A]: Journaller[F, B] = { new Narrow with Journaller[F, B] { @@ -88,18 +74,17 @@ object Journaller { } } - def withLogging1( - log: Log[F])(implicit + def withLogging1(log: Log[F])( + implicit F: FlatMap[F], measureDuration: MeasureDuration[F] ): Journaller[F, A] = { - Journaller( - self.append.withLogging1(log), - self.deleteTo.withLogging1(log)) + Journaller(self.append.withLogging1(log), self.deleteTo.withLogging1(log)) } - - def withFail(fail: Fail[F])(implicit F: MonadThrowable[F]): Journaller[F, A] = { + def withFail( + fail: Fail[F] + )(implicit F: MonadThrowable[F]): Journaller[F, A] = { new WithFail with Journaller[F, A] { val append = self.append.withFail(fail) @@ -108,4 +93,4 @@ object Journaller { } } } -} \ No newline at end of file +} diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala similarity index 86% rename from eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala rename to persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala index a27e2cc1..3274d267 100644 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Recovery.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala @@ -1,4 +1,4 @@ -package com.evolution.akkaeffect.eventsopircing.persistence +package com.evolutiongaming.akkaeffect.persistence import com.evolutiongaming.sstream.Stream diff --git a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala similarity index 75% rename from eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala rename to persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala index 2ef825f3..3ffc67d4 100644 --- a/eventsourcing-persistence/src/main/scala/com/evolution/akkaeffect/eventsopircing/persistence/Snapshot.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala @@ -1,4 +1,4 @@ -package com.evolution.akkaeffect.eventsopircing.persistence +package com.evolutiongaming.akkaeffect.persistence import java.time.Instant diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala new file mode 100644 index 00000000..a3e29cdf --- /dev/null +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala @@ -0,0 +1,222 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.persistence.SnapshotSelectionCriteria +import cats.syntax.all._ +import cats.{Applicative, FlatMap, ~>} +import com.evolutiongaming.akkaeffect.Fail +import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable} + +import java.time.Instant + +/** + * Describes communication with underlying snapshot storage + * + * @tparam A - snapshot + */ +trait Snapshotter[F[_], -A] { + + /** + * Saves a `snapshot` of this snapshotter' state. + * + * @return outer F[_] is about saving in background, inner F[_] is about saving completed + */ + def save(seqNr: SeqNr, snapshot: A): F[F[Instant]] + + /** + * Deletes the snapshot identified by `sequenceNr`. + * + * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed + */ + def delete(seqNr: SeqNr): F[F[Unit]] + + /** + * @see [[akka.persistence.Snapshotter.deleteSnapshots]] + * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed + */ + @deprecated( + "use Snapshotter.delete(Snapshotter.Criteria) instead", + "01-12-2023" + ) + def delete(criteria: SnapshotSelectionCriteria): F[F[Unit]] + + /** + * Deletes all snapshots matching `criteria`. + * + * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed + */ + def delete(criteria: Snapshotter.Criteria): F[F[Unit]] +} + +object Snapshotter { + + final case class Criteria(maxSequenceNr: Long = Long.MaxValue, + maxTimestamp: Long = Long.MaxValue, + minSequenceNr: Long = 0L, + minTimestamp: Long = 0L) { + def asAkka: SnapshotSelectionCriteria = + SnapshotSelectionCriteria( + maxSequenceNr, + maxTimestamp, + minSequenceNr, + minTimestamp + ) + } + + object Criteria { + def apply(criteria: SnapshotSelectionCriteria): Criteria = + new Criteria( + criteria.maxSequenceNr, + criteria.maxTimestamp, + criteria.minSequenceNr, + criteria.minTimestamp + ) + } + + def const[F[_], A](instant: F[F[Instant]], + unit: F[F[Unit]]): Snapshotter[F, A] = + new Snapshotter[F, A] { + + def save(seqNr: SeqNr, snapshot: A) = instant + + def delete(seqNr: SeqNr) = unit + + def delete(criteria: SnapshotSelectionCriteria) = unit + + def delete(criteria: Criteria): F[F[Unit]] = unit + } + + def empty[F[_]: Applicative, A]: Snapshotter[F, A] = { + const(Instant.ofEpochMilli(0L).pure[F].pure[F], ().pure[F].pure[F]) + } + + private sealed abstract class Convert + + private sealed abstract class MapK + + private sealed abstract class WithFail + + private sealed abstract class WithLogging + + implicit class SnapshotterOps[F[_], A](val self: Snapshotter[F, A]) + extends AnyVal { + + def mapK[G[_]: Applicative](f: F ~> G): Snapshotter[G, A] = { + new MapK with Snapshotter[G, A] { + + def save(seqNr: SeqNr, snapshot: A) = + f(self.save(seqNr, snapshot)).map { a => + f(a) + } + + def delete(seqNr: SeqNr) = f(self.delete(seqNr)).map { a => + f(a) + } + + def delete(criteria: SnapshotSelectionCriteria) = + delete(Criteria(criteria)) + + def delete(criteria: Criteria): G[G[Unit]] = + f(self.delete(criteria)).map { a => + f(a) + } + } + } + + def convert[B](f: B => F[A])(implicit F: FlatMap[F]): Snapshotter[F, B] = { + new Convert with Snapshotter[F, B] { + + def save(seqNr: SeqNr, snapshot: B) = f(snapshot).flatMap { a => + self.save(seqNr, a) + } + + def delete(seqNr: SeqNr) = self.delete(seqNr) + + def delete(criteria: SnapshotSelectionCriteria) = + delete(Criteria(criteria)) + + def delete(criteria: Criteria): F[F[Unit]] = self.delete(criteria) + } + } + + def withLogging1(log: Log[F])( + implicit + F: FlatMap[F], + measureDuration: MeasureDuration[F] + ): Snapshotter[F, A] = { + new WithLogging with Snapshotter[F, A] { + + def save(seqNr: SeqNr, snapshot: A) = { + for { + d <- MeasureDuration[F].start + r <- self.save(seqNr, snapshot) + } yield + for { + r <- r + d <- d + _ <- log.info(s"save snapshot at $seqNr in ${d.toMillis}ms") + } yield r + } + + def delete(seqNr: SeqNr) = { + for { + d <- MeasureDuration[F].start + r <- self.delete(seqNr) + } yield + for { + r <- r + d <- d + _ <- log.info(s"delete snapshot at $seqNr in ${d.toMillis}ms") + } yield r + } + + def delete(criteria: SnapshotSelectionCriteria) = { + delete(Criteria(criteria)) + } + + def delete(criteria: Criteria): F[F[Unit]] = { + for { + d <- MeasureDuration[F].start + r <- self.delete(criteria) + } yield + for { + r <- r + d <- d + _ <- log.info( + s"delete snapshots for $criteria in ${d.toMillis}ms" + ) + } yield r + } + + } + } + + def withFail(fail: Fail[F])(implicit + F: MonadThrowable[F]): Snapshotter[F, A] = { + new WithFail with Snapshotter[F, A] { + + def save(seqNr: SeqNr, snapshot: A) = { + fail.adapt(s"failed to save snapshot at $seqNr") { + self.save(seqNr, snapshot) + } + } + + def delete(seqNr: SeqNr) = { + fail.adapt(s"failed to delete snapshot at $seqNr") { + self.delete(seqNr) + } + } + + def delete(criteria: SnapshotSelectionCriteria) = { + delete(Criteria(criteria)) + } + + def delete(criteria: Criteria): F[F[Unit]] = { + fail.adapt(s"failed to delete snapshots for $criteria") { + self.delete(criteria) + } + } + + } + } + } +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala similarity index 63% rename from persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala rename to persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala index 8d7f9d2e..4a910d71 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/package.scala @@ -1,8 +1,5 @@ package com.evolutiongaming.akkaeffect -import akka.persistence.Recovery -import cats.Show - package object persistence { type SeqNr = Long @@ -17,6 +14,4 @@ package object persistence { type Timestamp = Long - - implicit val showRecovery: Show[Recovery] = Show.fromToString } \ No newline at end of file diff --git a/persistence/src/main/scala/akka/persistence/SnapshotterInterop.scala b/persistence/src/main/scala/akka/persistence/SnapshotterInterop.scala index 19a8b0c0..015c0dbf 100644 --- a/persistence/src/main/scala/akka/persistence/SnapshotterInterop.scala +++ b/persistence/src/main/scala/akka/persistence/SnapshotterInterop.scala @@ -1,7 +1,6 @@ package akka.persistence import java.time.Instant - import akka.persistence.SnapshotProtocol.{DeleteSnapshot, DeleteSnapshots, Request, SaveSnapshot} import akka.util.Timeout import cats.effect.Sync @@ -65,6 +64,10 @@ object SnapshotterInterop { case a: DeleteSnapshotsFailure => a.cause.raiseError[F, Unit] } } + + def delete(criteria: akkaeffect.persistence.Snapshotter.Criteria): F[F[Unit]] = { + delete(criteria.asAkka) + } } } } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/AppendOf.scala similarity index 69% rename from persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala rename to persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/AppendOf.scala index 1302a230..c17d2792 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Append.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/AppendOf.scala @@ -4,39 +4,18 @@ import akka.persistence._ import cats.effect.kernel.Async import cats.effect.{Deferred, Resource, Sync} import cats.implicits._ -import cats.{Applicative, FlatMap, Monad, ~>} import com.evolutiongaming.akkaeffect.util.AtomicRef import com.evolutiongaming.akkaeffect.{Act, Fail} import com.evolutiongaming.catshelper.CatsHelper._ -import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable, ToFuture} +import com.evolutiongaming.catshelper.{MonadThrowable, ToFuture} import scala.collection.immutable.Queue - -/** - * @see [[akka.persistence.PersistentActor.persistAllAsync]] - */ -trait Append[F[_], -A] { +object AppendOf { /** - * @param events to be saved, inner Nel[A] will be persisted atomically, outer Nel[_] is for batching - * @return SeqNr of last event + * @see [[akka.persistence.PersistentActor.persistAllAsync]] */ - def apply(events: Events[A]): F[F[SeqNr]] -} - -object Append { - - def const[F[_], A](seqNr: F[F[SeqNr]]): Append[F, A] = { - class Const - new Const with Append[F, A] { - def apply(events: Events[A]) = seqNr - } - } - - def empty[F[_]: Applicative, A]: Append[F, A] = const(SeqNr.Min.pure[F].pure[F]) - - private[akkaeffect] def adapter[F[_]: Async: ToFuture, A]( act: Act[F], actor: PersistentActor, @@ -60,7 +39,7 @@ object Append { .toNel .foldMapM { queue => for { - error <- error + error <- error result <- queue.foldMapM { _.complete(error.asLeft).void } } yield result } @@ -83,7 +62,7 @@ object Append { val eventsList = events.values.toList for { deferred <- Deferred[F, Either[Throwable, SeqNr]] - _ <- act { + _ <- act { ref.update { _.enqueue(deferred) } var left = size eventsList.foreach { events => @@ -124,47 +103,6 @@ object Append { } } - - implicit class AppendOps[F[_], A](val self: Append[F, A]) extends AnyVal { - - def mapK[G[_]: Applicative](f: F ~> G): Append[G, A] = { - events => f(self(events)).map { a => f(a) } - } - - def convert[B](f: B => F[A])(implicit F: Monad[F]): Append[F, B] = { - events => { - for { - events <- events.traverse(f) - seqNr <- self(events) - } yield seqNr - } - } - - - def narrow[B <: A]: Append[F, B] = events => self(events) - - def withLogging1( - log: Log[F])(implicit - F: FlatMap[F], - measureDuration: MeasureDuration[F] - ): Append[F, A] = events => { - for { - d <- MeasureDuration[F].start - r <- self(events) - } yield for { - r <- r - d <- d - _ <- log.debug(s"append ${ events.size } events in ${ d.toMillis }ms") - } yield r - } - - - def withFail(fail: Fail[F])(implicit F: MonadThrowable[F]): Append[F, A] = { - events => fail.adapt(s"failed to append $events") { self(events) } - } - } - - private[akkaeffect] trait Eventsourced { def lastSequenceNr: SeqNr @@ -188,7 +126,6 @@ object Append { def apply(cause: Throwable, event: A, seqNr: SeqNr): Unit } - private[akkaeffect] trait Adapter[F[_], A] { def value: Append[F, A] @@ -208,4 +145,6 @@ object Append { } } } + + } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsToOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsToOf.scala new file mode 100644 index 00000000..790ecebe --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsToOf.scala @@ -0,0 +1,21 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.persistence.DeleteEventsToInterop +import cats.effect.{Resource, Sync} +import com.evolutiongaming.catshelper.FromFuture + +import scala.concurrent.duration.FiniteDuration + +object DeleteEventsToOf { + + /** + * @see [[akka.persistence.Eventsourced.deleteMessages]] + */ + private[akkaeffect] def of[F[_]: Sync: FromFuture, A]( + persistentActor: akka.persistence.PersistentActor, + timeout: FiniteDuration + ): Resource[F, DeleteEventsTo[F]] = { + DeleteEventsToInterop(persistentActor, timeout) + } + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourced.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourced.scala index 01b003e3..7275f814 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourced.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourced.scala @@ -18,6 +18,8 @@ final case class EventSourced[+A]( object EventSourced { + implicit val showRecovery: Show[Recovery] = Show.fromToString + implicit val functorEventSourced: Functor[EventSourced] = new Functor[EventSourced] { def map[A, B](fa: EventSourced[A])(f: A => B) = fa.map(f) } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala new file mode 100644 index 00000000..ce7d93ff --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -0,0 +1,80 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.actor.Actor +import cats.effect.implicits.effectResourceOps +import cats.effect.{Async, Resource} +import cats.syntax.all._ +import com.evolutiongaming.akkaeffect._ +import com.evolutiongaming.catshelper.ToFuture + +import scala.reflect.ClassTag + +object EventSourcedActorOf { + + /** + * Describes lifecycle of entity with regards to event sourcing & PersistentActor + * Lifecycle phases: + * + * 1. RecoveryStarted: we have id in place and can decide whether we should continue with recovery + * 2. Recovering : reading snapshot and replaying events + * 3. Receiving : receiving commands and potentially storing events & snapshots + * 4. Termination : triggers all release hooks of allocated resources within previous phases + * + * @tparam S snapshot + * @tparam E event + * @tparam C command + */ + type Type[F[_], S, E, C] = EventSourcedOf[ + F, + Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]] + ] + + def actor[F[_]: Async: ToFuture, S, E, C: ClassTag]( + eventSourcedOf: Type[F, S, E, C], + eventSourcedStore: EventSourcedStore[F, S, E], + ): Actor = ActorOf[F] { actorCtx => + for { + eventSourced <- eventSourcedOf(actorCtx).toResource + recoveryStarted <- eventSourced.value + recovery <- eventSourcedStore.recover(eventSourced.eventSourcedId) + + recovering <- recoveryStarted( + recovery.snapshot.map(_.metadata.seqNr).getOrElse(SeqNr.Min), + recovery.snapshot.map(_.asOffer) + ) + + replaying = for { + replay <- recovering.replay + events = recovery.events + seqNrL <- events + .foldWhileM(SeqNr.Min) { + case (_, event) => + replay(event.event, event.seqNr).as(event.seqNr.asLeft[Unit]) + } + .toResource + seqNr <- seqNrL + .as(new IllegalStateException("should newer happened")) + .swap + .liftTo[F] + .toResource + } yield seqNr + + seqNr <- replaying.use(_.pure[F]).toResource + journaller <- eventSourcedStore.journaller(eventSourced.eventSourcedId) + snapshotter <- eventSourcedStore.snapshotter(eventSourced.eventSourcedId) + receive <- recovering.completed(seqNr, journaller, snapshotter) + } yield receive.contramapM[Envelope[Any]](_.cast[F, C]) + } + + private implicit class SnapshotOps[S](val snapshot: Snapshot[S]) + extends AnyVal { + + def asOffer: SnapshotOffer[S] = + SnapshotOffer( + SnapshotMetadata(snapshot.metadata.seqNr, snapshot.metadata.timestamp), + snapshot.snapshot + ) + + } + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 89d70d29..2bd619b7 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -1,11 +1,11 @@ package com.evolutiongaming.akkaeffect.persistence -import akka.persistence.SnapshotSelectionCriteria -import akka.persistence.journal.AsyncRecovery +import akka.persistence.{AtomicWrite, PersistentRepr, SnapshotSelectionCriteria} +import akka.persistence.journal.{AsyncRecovery, AsyncWriteJournal} import akka.persistence.snapshot.SnapshotStore -import cats.effect.{Async, Ref, Resource, Sync} +import cats.effect.implicits.effectResourceOps +import cats.effect.{Async, Clock, Ref, Resource, Sync} import cats.syntax.all._ -import com.evolution.akkaeffect.eventsopircing.persistence.{Event, EventSourcedStore, Recovery, Snapshot} import com.evolutiongaming.catshelper.{FromFuture, ToTry} import com.evolutiongaming.sstream.FoldWhile._ import com.evolutiongaming.sstream.Stream @@ -24,23 +24,29 @@ object EventSourcedStoreOf { * first events will be available for [[Stream#foldWhileM]] while tail still loaded by [[AsyncRecovery]] * * @param snapshotStore Akka Persistence snapshot (plugin) - * @param journal Akka Persistence journal (plugin) + * @param asyncRecovery Akka Persistence journal (plugin), recovery API + * @param asyncWrite Akka Persistence journal (plugin), storing API * @tparam F effect * @tparam S snapshot * @tparam E event * @return resource of [[EventSourcedStore]] */ - def fromAkka[F[_]: Async: ToTry, S, E](snapshotStore: SnapshotStore, - journal: AsyncRecovery, + def fromAkka[F[_]: Async: ToTry, S, E]( + snapshotStore: SnapshotStore, + asyncRecovery: AsyncRecovery, + asyncWrite: AsyncWriteJournal ): Resource[F, EventSourcedStore[F, S, E]] = { val eventSourcedStore = new EventSourcedStore[F, S, E] { - override def recover(id: EventSourcedStore.Id): F[Recovery[F, S, E]] = { + override def recover( + id: EventSourcedId + ): Resource[F, Recovery[F, S, E]] = { snapshotStore .loadAsync(id.value, SnapshotSelectionCriteria()) .liftTo[F] + .toResource .map { offer => new Recovery[F, S, E] { @@ -66,13 +72,13 @@ object EventSourcedStoreOf { buffer <- Ref[F].of(Vector.empty[Event[E]]) - highestSequenceNr <- journal + highestSequenceNr <- asyncRecovery .asyncReadHighestSequenceNr(id.value, fromSequenceNr) .liftTo[F] replayed <- Sync[F].delay { - journal.asyncReplayMessages( + asyncRecovery.asyncReplayMessages( id.value, fromSequenceNr, highestSequenceNr, @@ -134,6 +140,103 @@ object EventSourcedStoreOf { } } + override def journaller( + id: EventSourcedId + ): Resource[F, Journaller[F, E]] = { + + val journaller = new Journaller[F, E] { + + override def append: Append[F, E] = new Append[F, E] { + + override def apply(events: Events[E]): F[F[SeqNr]] = { + + val atomicWrites = events.values.toList.map { events => + val persistent = events.toList.map { event => + PersistentRepr(event, persistenceId = id.value) + } + AtomicWrite(persistent) + } + + Sync[F].delay { + + asyncWrite + .asyncWriteMessages(atomicWrites) + .liftTo[F] + .map { results => + ??? // TODO: convert [[results]] into latest seqNr + } + + } + + } + } + + override def deleteTo: DeleteEventsTo[F] = new DeleteEventsTo[F] { + + override def apply(seqNr: SeqNr): F[F[Unit]] = { + + Sync[F].delay { + asyncWrite + .asyncDeleteMessagesTo(id.value, seqNr) + .liftTo[F] + + } + } + } + } + + journaller.pure[Resource[F, *]] + + } + + override def snapshotter( + id: EventSourcedId + ): Resource[F, Snapshotter[F, S]] = { + + val snapshotter = new Snapshotter[F, S] { + + override def save(seqNr: SeqNr, snapshot: S): F[F[Instant]] = { + for { + timestamp <- Clock[F].realTimeInstant + metadata = akka.persistence.SnapshotMetadata( + id.value, + seqNr, + timestamp.toEpochMilli + ) + saving <- Sync[F].delay { + snapshotStore + .saveAsync(metadata, snapshot) + .liftTo[F] + } + } yield saving as timestamp + } + + override def delete(seqNr: SeqNr): F[F[Unit]] = { + Sync[F].delay { + val metadata = akka.persistence.SnapshotMetadata(id.value, seqNr) + snapshotStore.deleteAsync(metadata).liftTo[F] + } + } + + override def delete( + criteria: SnapshotSelectionCriteria + ): F[F[Unit]] = { + Sync[F].delay { + snapshotStore.deleteAsync(id.value, criteria).liftTo[F] + } + } + + override def delete(criteria: Snapshotter.Criteria): F[F[Unit]] = { + Sync[F].delay { + snapshotStore.deleteAsync(id.value, criteria.asAkka).liftTo[F] + } + } + + } + + snapshotter.pure[Resource[F, *]] + + } } eventSourcedStore.pure[Resource[F, *]] diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala deleted file mode 100644 index 59c0ae63..00000000 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Events.scala +++ /dev/null @@ -1,144 +0,0 @@ -package com.evolutiongaming.akkaeffect.persistence - -import cats.data.{NonEmptyList => Nel} -import cats.syntax.all._ -import cats.kernel.{Eq, Semigroup} -import cats.{Apply, Eval, NonEmptyTraverse, Order, Show} - -/** - * - * @param values inner Nel[A] will be persisted atomically, - * This applies some restrictions on persistence layer. - * Please do not overuse this feature - * @tparam A event - */ -final case class Events[+A](values: Nel[Nel[A]]) { self => - - override def toString = { - Events - .show(Show.fromToString[A]) - .show(self) - } - - def map[A1](f: A => A1): Events[A1] = Events(values.map(_.map(f))) -} - -object Events { - - implicit val traverseEvents: NonEmptyTraverse[Events] = new NonEmptyTraverse[Events] { - - def nonEmptyTraverse[G[_], A, B](fa: Events[A])(f: A => G[B])(implicit G: Apply[G]) = { - fa - .values - .nonEmptyTraverse { _.nonEmptyTraverse(f) } - .map { a => Events(a) } - } - - def reduceLeftTo[A, B](fa: Events[A])(f: A => B)(g: (B, A) => B) = { - fa - .values - .reduceLeftTo(_.reduceLeftTo(f)(g))((b, as) => as.foldLeft(b)(g)) - } - - def reduceRightTo[A, B](fa: Events[A])(f: A => B)(g: (A, Eval[B]) => Eval[B]) = { - fa - .values - .reduceRightTo(_.reduceRightTo(f)(g))((as, b) => b.map(b => as.foldRight(b)(g))) - .flatten - } - - def foldLeft[A, B](fa: Events[A], b: B)(f: (B, A) => B) = { - fa - .values - .foldLeft(b) { (b, as) => as.foldLeft(b)(f) } - } - - def foldRight[A, B](fa: Events[A], b: Eval[B])(f: (A, Eval[B]) => Eval[B]) = { - fa - .values - .foldRight(b) { (as, b) => as.foldRight(b)(f) } - } - } - - - implicit def semigroupEvents[A]: Semigroup[Events[A]] = { - (a, b) => Events(a.values.combine(b.values)) - } - - - implicit def orderEvents[A](implicit A: Order[A]): Order[Events[A]] = Order.by { _.values } - - - implicit def show[A: Show]: Show[Events[A]] = { - events => - val str = events.values match { - case Nel(events, Nil) => events.mkString_(",") - case events => events.map { _.toList.mkString(",") }.mkString_("[", "],[", "]") - } - s"${ events.productPrefix }($str)" - } - - - implicit def eqEvents[A: Eq]: Eq[Events[A]] = Eq.by { _.values } - - - /** - * @return single batch of attached events - */ - def of[A](a: A, as: A*): Events[A] = attached(a, as: _*) - - - /** - * @return single batch of attached events - */ - def fromList[A](as: List[A]): Option[Events[A]] = as.toNel.map { as => Events(Nel.of(as)) } - - - def batched[A](a: Nel[A], as: Nel[A]*): Events[A] = Events(Nel(a, as.toList)) - - /** - * @return single batch of attached events - */ - def attached[A](a: A, as: A*): Events[A] = Events(Nel.of(Nel(a, as.toList))) - - /** - * @return detached batches of single event - */ - def detached[A](a: A, as: A*): Events[A] = Events(Nel(a, as.toList).map { a => Nel.of(a) }) - - - implicit class EventsOps[A](val self: Events[A]) extends AnyVal { - - def prepend[B >: A](events: Nel[B]): Events[B] = self.copy(values = events :: self.values) - - def prepend[B >: A](event: B): Events[B] = self.prepend(Nel.of(event)) - - def prepend[B >: A](events: List[B]): Events[B] = events.toNel.fold[Events[B]](self) { as => self.prepend(as) } - - - def append[B >: A](events: Nel[B]): Events[B] = self.copy(values = self.values :+ events) - - def append[B >: A](event: B): Events[B] = self.append(Nel.of(event)) - - def append[B >: A](events: List[B]): Events[B] = events.toNel.fold[Events[B]](self) { as => self.append(as) } - } - - - object implicits { - - implicit class ListOpsEvents[E](val self: List[E]) extends AnyVal { - - def toEvents: Option[Events[E]] = Events.fromList(self) - - def toEventsDetached: Option[Events[E]] = self.toNel.map { as => Events(as.map(a => Nel.of(a))) } - } - - - implicit class NelOpsEvents[E](val self: Nel[E]) extends AnyVal { - - def toEvents: Events[E] = Events.batched(self) - - def toEventsDetached: Events[E] = Events(self.map { a => Nel.of(a) }) - } - } -} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala index c5f59fb1..97b9e2f5 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala @@ -14,7 +14,6 @@ import com.evolutiongaming.catshelper.{FromFuture, Memoize, ToFuture, ToTry} import scala.collection.immutable.Seq import scala.concurrent.duration._ - object PersistentActorOf { /** @@ -30,7 +29,10 @@ object PersistentActorOf { * @tparam E event * @tparam C command */ - type Type[F[_], S, E, C] = EventSourcedOf[F, Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]]] + type Type[F[_], S, E, C] = EventSourcedOf[ + F, + Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]] + ] def apply[F[_]: Async: ToFuture: FromFuture: ToTry]( eventSourcedOf: Type[F, Any, Any, Any], @@ -41,13 +43,19 @@ object PersistentActorOf { lazy val (actorCtx, act, eventSourced) = { val actorCtxRef = AtomicRef(ActorCtx[F](context)) - val actorCtx = ActorCtx.flatten(context, Sync[F].delay { actorCtxRef.get() }) + val actorCtx = ActorCtx.flatten(context, Sync[F].delay { + actorCtxRef.get() + }) val act = Act.Adapter(context.self) val eventSourced = act.sync { eventSourcedOf(actorCtx) - .adaptError { case error => - val path = self.path.toStringWithoutAddress - ActorError(s"$path failed to allocate eventSourced: $error", error) + .adaptError { + case error => + val path = self.path.toStringWithoutAddress + ActorError( + s"$path failed to allocate eventSourced: $error", + error + ) } .toTry .get @@ -55,9 +63,12 @@ object PersistentActorOf { (actorCtxRef, act, eventSourced) } - private def actorError(msg: String, cause: Option[Throwable]): Throwable = { + private def actorError(msg: String, + cause: Option[Throwable]): Throwable = { val path = self.path.toStringWithoutAddress - val causeStr: String = cause.foldMap { a => s": $a" } + val causeStr: String = cause.foldMap { a => + s": $a" + } ActorError(s"$path $persistenceId $msg$causeStr", cause) } @@ -67,27 +78,26 @@ object PersistentActorOf { } } - case class Resources( - append: Append.Adapter[F, Any], - deleteEventsTo: DeleteEventsTo[F]) + case class Resources(append: AppendOf.Adapter[F, Any], + deleteEventsTo: DeleteEventsTo[F]) lazy val (resources: Resources, release) = { - val stopped = Memoize.sync[F, Throwable] { Sync[F].delay { actorError("has been stopped", none) } } + val stopped = Memoize.sync[F, Throwable] { + Sync[F].delay { actorError("has been stopped", none) } + } val result = for { - stopped <- stopped.toResource - act <- act.value.pure[Resource[F, *]] - append <- Append.adapter[F, Any](act, actor, stopped) - deleteEventsTo <- DeleteEventsTo.of(actor, timeout) + stopped <- stopped.toResource + act <- act.value.pure[Resource[F, *]] + append <- AppendOf.adapter[F, Any](act, actor, stopped) + deleteEventsTo <- DeleteEventsToOf.of(actor, timeout) } yield { Resources(append, deleteEventsTo) } - result - .allocated - .toTry - .get + result.allocated.toTry.get } - val persistence: PersistenceVar[F, Any, Any, Any] = PersistenceVar[F, Any, Any, Any](act.value, context) + val persistence: PersistenceVar[F, Any, Any, Any] = + PersistenceVar[F, Any, Any, Any](act.value, context) override def preStart(): Unit = { super.preStart() @@ -107,16 +117,22 @@ object PersistentActorOf { eventSourced.pluginIds.snapshot getOrElse super.snapshotPluginId } - override protected def onPersistFailure(cause: Throwable, event: Any, seqNr: Long) = { - val error = actorError(s"[$seqNr] persist failed for $event", cause.some) + override protected def onPersistFailure(cause: Throwable, + event: Any, + seqNr: Long) = { + val error = + actorError(s"[$seqNr] persist failed for $event", cause.some) act.sync { resources.append.onError(error, event, seqNr) } super.onPersistFailure(cause, event, seqNr) } - override protected def onPersistRejected(cause: Throwable, event: Any, seqNr: Long) = { - val error = actorError(s"[$seqNr] persist rejected for $event", cause.some) + override protected def onPersistRejected(cause: Throwable, + event: Any, + seqNr: Long) = { + val error = + actorError(s"[$seqNr] persist rejected for $event", cause.some) act.sync { resources.append.onError(error, event, seqNr) } @@ -124,9 +140,11 @@ object PersistentActorOf { } def receiveRecover: Receive = act.receive { - case ap.SnapshotOffer(m, s) => persistence.snapshotOffer(lastSeqNr(), SnapshotOffer(SnapshotMetadata(m), s)) - case RecoveryCompleted => recoveryCompleted(lastSeqNr()) - case event => persistence.event(lastSeqNr(), event) + case ap.SnapshotOffer(m, s) => + persistence + .snapshotOffer(lastSeqNr(), SnapshotOffer(SnapshotMetadata(m), s)) + case RecoveryCompleted => recoveryCompleted(lastSeqNr()) + case event => persistence.event(lastSeqNr(), event) } def receiveCommand: Receive = act.receive { @@ -135,27 +153,39 @@ object PersistentActorOf { } override def persist[A](event: A)(f: A => Unit): Unit = { - super.persist(event) { a => act.sync { f(a) } } + super.persist(event) { a => + act.sync { f(a) } + } } override def persistAll[A](events: Seq[A])(f: A => Unit): Unit = { - super.persistAll(events) { a => act.sync { f(a) } } + super.persistAll(events) { a => + act.sync { f(a) } + } } override def persistAsync[A](event: A)(f: A => Unit): Unit = { - super.persistAsync(event) { a => act.sync { f(a) } } + super.persistAsync(event) { a => + act.sync { f(a) } + } } override def persistAllAsync[A](events: Seq[A])(f: A => Unit) = { - super.persistAllAsync(events) { a => act.sync { f(a) } } + super.persistAllAsync(events) { a => + act.sync { f(a) } + } } override def defer[A](event: A)(f: A => Unit): Unit = { - super.defer(event) { a => act.sync { f(a) } } + super.defer(event) { a => + act.sync { f(a) } + } } override def deferAsync[A](event: A)(f: A => Unit): Unit = { - super.deferAsync(event) { a => act.sync { f(a) } } + super.deferAsync(event) { a => + act.sync { f(a) } + } } override def postStop() = { @@ -171,8 +201,10 @@ object PersistentActorOf { } private def recoveryCompleted(seqNr: SeqNr): Unit = { - val journaller = Journaller[F, Any](resources.append.value, resources.deleteEventsTo).withFail(fail) - val snapshotter = Snapshotter[F, Any](actor, timeout).withFail(fail) + val journaller = + Journaller[F, Any](resources.append.value, resources.deleteEventsTo) + .withFail(fail) + val snapshotter = SnapshotterOf[F, Any](actor, timeout).withFail(fail) persistence.recoveryCompleted(seqNr, journaller, snapshotter) } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala deleted file mode 100644 index f089bf12..00000000 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshotter.scala +++ /dev/null @@ -1,164 +0,0 @@ -package com.evolutiongaming.akkaeffect.persistence - -import java.time.Instant -import akka.persistence.{SnapshotSelectionCriteria, Snapshotter => _, _} -import cats.effect.Sync -import cats.syntax.all._ -import cats.{Applicative, FlatMap, ~>} -import com.evolutiongaming.akkaeffect.Fail -import com.evolutiongaming.catshelper.{FromFuture, Log, MeasureDuration, MonadThrowable} - -import scala.concurrent.duration.FiniteDuration - -/** - * Describes communication with underlying snapshot storage - * - * @tparam A - snapshot - */ -trait Snapshotter[F[_], -A] { - - /** - * @see [[akka.persistence.Snapshotter.saveSnapshot]] - * @return outer F[_] is about saving in background, inner F[_] is about saving completed - */ - def save(seqNr: SeqNr, snapshot: A): F[F[Instant]] - - /** - * @see [[akka.persistence.Snapshotter.deleteSnapshot]] - * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed - */ - def delete(seqNr: SeqNr): F[F[Unit]] - - /** - * @see [[akka.persistence.Snapshotter.deleteSnapshots]] - * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed - */ - def delete(criteria: SnapshotSelectionCriteria): F[F[Unit]] -} - -object Snapshotter { - - def const[F[_], A](instant: F[F[Instant]], unit: F[F[Unit]]): Snapshotter[F, A] = new Snapshotter[F, A] { - - def save(seqNr: SeqNr, snapshot: A) = instant - - def delete(seqNr: SeqNr) = unit - - def delete(criteria: SnapshotSelectionCriteria) = unit - } - - def empty[F[_]: Applicative, A]: Snapshotter[F, A] = { - const(Instant.ofEpochMilli(0L).pure[F].pure[F], ().pure[F].pure[F]) - } - - - private[akkaeffect] def apply[F[_] : Sync : FromFuture, A]( - snapshotter: akka.persistence.Snapshotter, - timeout: FiniteDuration - ): Snapshotter[F, A] = { - SnapshotterInterop(snapshotter, timeout) - } - - private sealed abstract class Convert - - private sealed abstract class MapK - - private sealed abstract class WithFail - - private sealed abstract class WithLogging - - implicit class SnapshotterOps[F[_], A](val self: Snapshotter[F, A]) extends AnyVal { - - def mapK[G[_]: Applicative](f: F ~> G): Snapshotter[G, A] = { - new MapK with Snapshotter[G, A] { - - def save(seqNr: SeqNr, snapshot: A) = f(self.save(seqNr, snapshot)).map { a => f(a) } - - def delete(seqNr: SeqNr) = f(self.delete(seqNr)).map { a => f(a) } - - def delete(criteria: SnapshotSelectionCriteria) = f(self.delete(criteria)).map { a => f(a) } - } - } - - - def convert[B](f: B => F[A])(implicit F: FlatMap[F]): Snapshotter[F, B] = { - new Convert with Snapshotter[F, B] { - - def save(seqNr: SeqNr, snapshot: B) = f(snapshot).flatMap { a => self.save(seqNr, a) } - - def delete(seqNr: SeqNr) = self.delete(seqNr) - - def delete(criteria: SnapshotSelectionCriteria) = self.delete(criteria) - } - } - - def withLogging1( - log: Log[F])(implicit - F: FlatMap[F], - measureDuration: MeasureDuration[F] - ): Snapshotter[F, A] = { - new WithLogging with Snapshotter[F, A] { - - def save(seqNr: SeqNr, snapshot: A) = { - for { - d <- MeasureDuration[F].start - r <- self.save(seqNr, snapshot) - } yield for { - r <- r - d <- d - _ <- log.info(s"save snapshot at $seqNr in ${ d.toMillis }ms") - } yield r - } - - def delete(seqNr: SeqNr) = { - for { - d <- MeasureDuration[F].start - r <- self.delete(seqNr) - } yield for { - r <- r - d <- d - _ <- log.info(s"delete snapshot at $seqNr in ${ d.toMillis }ms") - } yield r - } - - def delete(criteria: SnapshotSelectionCriteria) = { - for { - d <- MeasureDuration[F].start - r <- self.delete(criteria) - } yield for { - r <- r - d <- d - _ <- log.info(s"delete snapshots for $criteria in ${ d.toMillis }ms") - } yield r - } - } - } - - - def withFail( - fail: Fail[F])(implicit - F: MonadThrowable[F] - ): Snapshotter[F, A] = { - new WithFail with Snapshotter[F, A] { - - def save(seqNr: SeqNr, snapshot: A) = { - fail.adapt(s"failed to save snapshot at $seqNr") { - self.save(seqNr, snapshot) - } - } - - def delete(seqNr: SeqNr) = { - fail.adapt(s"failed to delete snapshot at $seqNr") { - self.delete(seqNr) - } - } - - def delete(criteria: SnapshotSelectionCriteria) = { - fail.adapt(s"failed to delete snapshots for $criteria") { - self.delete(criteria) - } - } - } - } - } -} \ No newline at end of file diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterOf.scala new file mode 100644 index 00000000..b5b44005 --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterOf.scala @@ -0,0 +1,18 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.persistence.SnapshotterInterop +import cats.effect.Sync +import com.evolutiongaming.catshelper.FromFuture + +import scala.concurrent.duration.FiniteDuration + +object SnapshotterOf { + + private[akkaeffect] def apply[F[_]: Sync: FromFuture, A]( + snapshotter: akka.persistence.Snapshotter, + timeout: FiniteDuration + ): Snapshotter[F, A] = { + SnapshotterInterop(snapshotter, timeout) + } + +} diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/AppendTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/AppendTest.scala index acd1bebb..30cf4256 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/AppendTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/AppendTest.scala @@ -28,11 +28,11 @@ class AppendTest extends AsyncFunSuite with Matchers { case class Event(fa: F[Unit]) - def eventsourced(act: Act[F], ref: Ref[F, Queue[F[Unit]]]): F[Append.Eventsourced] = { + def eventsourced(act: Act[F], ref: Ref[F, Queue[F[Unit]]]): F[AppendOf.Eventsourced] = { Ref[F] .of(SeqNr.Min) .map { seqNr => - new Append.Eventsourced { + new AppendOf.Eventsourced { def lastSequenceNr = seqNr.get.toTry.get @@ -56,7 +56,7 @@ class AppendTest extends AsyncFunSuite with Matchers { for { ref <- Ref[F].of(Queue.empty[F[Unit]]) eventsourced <- eventsourced(act, ref) - result <- Append + result <- AppendOf .adapter[F, Int](act, eventsourced, stopped.pure[F]) .use { append => diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/InstrumentEventSourced.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/InstrumentEventSourced.scala index 6e2abc12..dd0c028d 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/InstrumentEventSourced.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/InstrumentEventSourced.scala @@ -130,6 +130,20 @@ object InstrumentEventSourced { } yield a } } + + def delete(criteria: Snapshotter.Criteria): F[F[Unit]] = { + for { + _ <- record(Action.DeleteSnapshots(criteria.asAkka)) + a <- snapshotter.delete(criteria) + _ <- record(Action.DeleteSnapshotsOuter) + } yield { + for { + a <- a + _ <- record(Action.DeleteSnapshotsInner) + } yield a + } + } + } for { diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterTest.scala index 7c6142d8..176582c0 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/SnapshotterTest.scala @@ -33,7 +33,7 @@ class SnapshotterTest extends AsyncFunSuite with ActorSuite with Matchers { override def preStart() = { super.preStart() - val snapshotter = Snapshotter[F, Any](actor, 1.minute) + val snapshotter = SnapshotterOf[F, Any](actor, 1.minute) deferred.complete(snapshotter).toFuture () } From 3d3d1ee3d9ab293ab23ce9ca93891adb2fa148f7 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 21 Nov 2023 16:36:44 +0100 Subject: [PATCH 06/29] wip: track seqNr in Append --- .../persistence/EventSourcedStore.scala | 4 +-- .../persistence/EventSourcedActorOf.scala | 3 +- .../persistence/EventSourcedStoreOf.scala | 35 +++++++++++-------- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 91598f70..48413fd8 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -12,14 +12,14 @@ import cats.effect.kernel.Resource trait EventSourcedStore[F[_], S, E] { /** - * Start recovery by retrieving snapshot (eager, happening on outer F) + * Start recovery by retrieving snapshot (eager, happening on resource allocation) * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) * @param id persistent ID * @return [[Recovery]] represents started recovery, resource will be released upon actor termination */ def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] - def journaller(id: EventSourcedId): Resource[F, Journaller[F, E]] + def journaller(id: EventSourcedId, seqNr: SeqNr): Resource[F, Journaller[F, E]] def snapshotter(id: EventSourcedId): Resource[F, Snapshotter[F, S]] } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index ce7d93ff..bb159a2a 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -60,7 +60,8 @@ object EventSourcedActorOf { } yield seqNr seqNr <- replaying.use(_.pure[F]).toResource - journaller <- eventSourcedStore.journaller(eventSourced.eventSourcedId) + journaller <- eventSourcedStore + .journaller(eventSourced.eventSourcedId, seqNr) snapshotter <- eventSourcedStore.snapshotter(eventSourced.eventSourcedId) receive <- recovering.completed(seqNr, journaller, snapshotter) } yield receive.contramapM[Envelope[Any]](_.cast[F, C]) diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 2bd619b7..2884611d 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -140,11 +140,10 @@ object EventSourcedStoreOf { } } - override def journaller( - id: EventSourcedId - ): Resource[F, Journaller[F, E]] = { + override def journaller(id: EventSourcedId, + seqNr: SeqNr): Resource[F, Journaller[F, E]] = { - val journaller = new Journaller[F, E] { + def journaller(seqNr: Ref[F, SeqNr]) = new Journaller[F, E] { override def append: Append[F, E] = new Append[F, E] { @@ -157,17 +156,22 @@ object EventSourcedStoreOf { AtomicWrite(persistent) } - Sync[F].delay { + seqNr + .updateAndGet(_ + events.size) + .flatMap { seqNr => + Sync[F].delay { + + asyncWrite + .asyncWriteMessages(atomicWrites) + .liftTo[F] + .flatMap { results => + results.sequence + .liftTo[F] + .as(seqNr) + } - asyncWrite - .asyncWriteMessages(atomicWrites) - .liftTo[F] - .map { results => - ??? // TODO: convert [[results]] into latest seqNr } - - } - + } } } @@ -185,7 +189,10 @@ object EventSourcedStoreOf { } } - journaller.pure[Resource[F, *]] + Ref[F] + .of(seqNr) + .map(journaller) + .toResource } From d3f7f5c858e71b8044815844e42d12de43b2a904 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Wed, 22 Nov 2023 13:28:20 +0100 Subject: [PATCH 07/29] wip: add docs & fix compile err --- .../akkaeffect/persistence/EventSourcedStore.scala | 11 +++++++++++ .../akkaeffect/persistence/EventSourcedActorOf.scala | 8 ++++---- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 48413fd8..97589e36 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -19,7 +19,18 @@ trait EventSourcedStore[F[_], S, E] { */ def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] + /** + * Create [[Journaller]] capable of persisting and deleting events + * @param id persistent ID + * @param seqNr recovered [[SeqNr]] or [[SeqNr.Min]] + * @return resource will be released upon actor termination + */ def journaller(id: EventSourcedId, seqNr: SeqNr): Resource[F, Journaller[F, E]] + /** + * Create [[Snapshotter]] capable of persisting and deleting snapshots + * @param id persistent ID + * @return resource will be released upon actor termination + */ def snapshotter(id: EventSourcedId): Resource[F, Snapshotter[F, S]] } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index bb159a2a..2b11b327 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -35,8 +35,9 @@ object EventSourcedActorOf { ): Actor = ActorOf[F] { actorCtx => for { eventSourced <- eventSourcedOf(actorCtx).toResource + persistentId = eventSourced.eventSourcedId recoveryStarted <- eventSourced.value - recovery <- eventSourcedStore.recover(eventSourced.eventSourcedId) + recovery <- eventSourcedStore.recover(persistentId) recovering <- recoveryStarted( recovery.snapshot.map(_.metadata.seqNr).getOrElse(SeqNr.Min), @@ -60,9 +61,8 @@ object EventSourcedActorOf { } yield seqNr seqNr <- replaying.use(_.pure[F]).toResource - journaller <- eventSourcedStore - .journaller(eventSourced.eventSourcedId, seqNr) - snapshotter <- eventSourcedStore.snapshotter(eventSourced.eventSourcedId) + journaller <- eventSourcedStore.journaller(persistentId, seqNr) + snapshotter <- eventSourcedStore.snapshotter(persistentId) receive <- recovering.completed(seqNr, journaller, snapshotter) } yield receive.contramapM[Envelope[Any]](_.cast[F, C]) } From f5079cd661b36d65a551ce1f340e1c817d986bae Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Wed, 22 Nov 2023 17:24:02 +0100 Subject: [PATCH 08/29] wip: implement EventSourcedStore creation from Akka persistent/snapshot plugins --- .../scala/akka/persistence/PluginLoader.scala | 43 ++++++++ .../persistence/EventSourcedActorOf.scala | 16 +-- .../persistence/EventSourcedStoreOf.scala | 100 +++++++++++++++--- 3 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 persistence/src/main/scala/akka/persistence/PluginLoader.scala diff --git a/persistence/src/main/scala/akka/persistence/PluginLoader.scala b/persistence/src/main/scala/akka/persistence/PluginLoader.scala new file mode 100644 index 00000000..454bf966 --- /dev/null +++ b/persistence/src/main/scala/akka/persistence/PluginLoader.scala @@ -0,0 +1,43 @@ +package akka.persistence + +import akka.actor.ExtendedActorSystem +import akka.util.Reflect + +import scala.reflect.ClassTag +import scala.util.control.NonFatal + +object PluginLoader { + + /** + * Instantiate plugin of type [[T]] configured by path [[configPath]] + * @param system akka system + * @param configPath plugin config path + * @tparam T plugin type + * @return instance of plugin or thrown [[Exception]] + */ + def loadPlugin[T: ClassTag](system: ExtendedActorSystem, + configPath: String): T = { + + val config = system.settings.config.getConfig(configPath) + val className = config.getString("class") + + if (className.isEmpty) + throw new IllegalArgumentException( + s"Plugin class name must be defined in config property [$configPath.class]" + ) + system.log.debug(s"Create plugin: $configPath $className") + + val clazz = + system.dynamicAccess.getClassFor[T](className).get.asInstanceOf[Class[T]] + + try Reflect.instantiate[T](clazz, List(config, configPath)) + catch { + case NonFatal(_) => + try Reflect.instantiate[T](clazz, List(config)) + catch { + case NonFatal(_) => Reflect.instantiate[T](clazz, List.empty) + } + } + } + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index 2b11b327..5f5c81a7 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -24,20 +24,20 @@ object EventSourcedActorOf { * @tparam E event * @tparam C command */ - type Type[F[_], S, E, C] = EventSourcedOf[ - F, + type Lifecycle[F[_], S, E, C] = Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]] - ] def actor[F[_]: Async: ToFuture, S, E, C: ClassTag]( - eventSourcedOf: Type[F, S, E, C], - eventSourcedStore: EventSourcedStore[F, S, E], + eventSourcedOf: EventSourcedOf[F, Lifecycle[F, S, E, C]], + eventSourcedStoreOf: EventSourcedStoreOf[F], ): Actor = ActorOf[F] { actorCtx => for { eventSourced <- eventSourcedOf(actorCtx).toResource persistentId = eventSourced.eventSourcedId recoveryStarted <- eventSourced.value - recovery <- eventSourcedStore.recover(persistentId) + + store <- eventSourcedStoreOf[S, E](eventSourced) + recovery <- store.recover(persistentId) recovering <- recoveryStarted( recovery.snapshot.map(_.metadata.seqNr).getOrElse(SeqNr.Min), @@ -61,8 +61,8 @@ object EventSourcedActorOf { } yield seqNr seqNr <- replaying.use(_.pure[F]).toResource - journaller <- eventSourcedStore.journaller(persistentId, seqNr) - snapshotter <- eventSourcedStore.snapshotter(persistentId) + journaller <- store.journaller(persistentId, seqNr) + snapshotter <- store.snapshotter(persistentId) receive <- recovering.completed(seqNr, journaller, snapshotter) } yield receive.contramapM[Envelope[Any]](_.cast[F, C]) } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 2884611d..1f03b4b9 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -1,12 +1,23 @@ package com.evolutiongaming.akkaeffect.persistence -import akka.persistence.{AtomicWrite, PersistentRepr, SnapshotSelectionCriteria} -import akka.persistence.journal.{AsyncRecovery, AsyncWriteJournal} +import akka.actor.ExtendedActorSystem +import akka.persistence.Persistence.{ + JournalFallbackConfigPath, + SnapshotStoreFallbackConfigPath +} +import akka.persistence.journal.AsyncWriteJournal import akka.persistence.snapshot.SnapshotStore +import akka.persistence.{ + AtomicWrite, + PersistentRepr, + PluginLoader, + SnapshotSelectionCriteria +} +import cats.Applicative +import cats.effect._ import cats.effect.implicits.effectResourceOps -import cats.effect.{Async, Clock, Ref, Resource, Sync} import cats.syntax.all._ -import com.evolutiongaming.catshelper.{FromFuture, ToTry} +import com.evolutiongaming.catshelper.{FromFuture, SerialRef, ToTry} import com.evolutiongaming.sstream.FoldWhile._ import com.evolutiongaming.sstream.Stream @@ -14,27 +25,86 @@ import java.time.Instant import scala.concurrent.Future import scala.util.Try +trait EventSourcedStoreOf[F[_]] { + + def apply[S, E]( + eventSourced: EventSourced[?] + ): Resource[F, EventSourcedStore[F, S, E]] + +} + object EventSourcedStoreOf { + def fromAkka[F[_]: Async: ToTry]( + system: ExtendedActorSystem + ): F[EventSourcedStoreOf[F]] = { + + SerialRef[F] + .of(Map.empty[String, Any]) + .map { cache => + def cached[A](id: String)(fa: F[A]): F[A] = + cache.modify { plugins => + plugins.get(id) match { + + case Some(plugin) => + Applicative[F].pure { + plugins -> plugin.asInstanceOf[A] + } + + case None => + for { + plugin <- fa + } yield { + plugins.updated(id, plugin) -> plugin + } + + } + } + + new EventSourcedStoreOf[F] { + + override def apply[S, E]( + eventSourced: EventSourced[_], + ): Resource[F, EventSourcedStore[F, S, E]] = { + val journalPath = eventSourced.pluginIds.journal getOrElse JournalFallbackConfigPath + val snapshotPath = eventSourced.pluginIds.snapshot getOrElse SnapshotStoreFallbackConfigPath + + val journalPlugin = Sync[F].delay { + PluginLoader.loadPlugin[AsyncWriteJournal](system, journalPath) + } + val snapshotPlugin = Sync[F].delay { + PluginLoader.loadPlugin[SnapshotStore](system, snapshotPath) + } + + for { + journalPlugin <- cached(journalPath)(journalPlugin).toResource + snapshotPlugin <- cached(snapshotPath)(snapshotPlugin).toResource + store <- fromAkkaPlugins[F, S, E](snapshotPlugin, journalPlugin) + } yield store + } + + } + } + + } + /** * [[EventSourcedStore]] implementation based on Akka Persistence API. * - * The implementation delegates snapshot and events load to [[SnapshotStore]] and [[AsyncRecovery]]. + * The implementation delegates snapshot and events load to [[SnapshotStore]] and [[AsyncWriteJournal]]. * Snapshot loaded on [[EventSourcedStore#recover]] F while events loaded lazily: - * first events will be available for [[Stream#foldWhileM]] while tail still loaded by [[AsyncRecovery]] + * first events will be available for [[Stream#foldWhileM]] while tail still loaded by [[AsyncWriteJournal]] * * @param snapshotStore Akka Persistence snapshot (plugin) - * @param asyncRecovery Akka Persistence journal (plugin), recovery API - * @param asyncWrite Akka Persistence journal (plugin), storing API + * @param asyncJournal Akka Persistence journal (plugin) * @tparam F effect * @tparam S snapshot * @tparam E event * @return resource of [[EventSourcedStore]] */ - def fromAkka[F[_]: Async: ToTry, S, E]( + private[persistence] def fromAkkaPlugins[F[_]: Async: ToTry, S, E]( snapshotStore: SnapshotStore, - asyncRecovery: AsyncRecovery, - asyncWrite: AsyncWriteJournal + asyncJournal: AsyncWriteJournal ): Resource[F, EventSourcedStore[F, S, E]] = { val eventSourcedStore = new EventSourcedStore[F, S, E] { @@ -72,13 +142,13 @@ object EventSourcedStoreOf { buffer <- Ref[F].of(Vector.empty[Event[E]]) - highestSequenceNr <- asyncRecovery + highestSequenceNr <- asyncJournal .asyncReadHighestSequenceNr(id.value, fromSequenceNr) .liftTo[F] replayed <- Sync[F].delay { - asyncRecovery.asyncReplayMessages( + asyncJournal.asyncReplayMessages( id.value, fromSequenceNr, highestSequenceNr, @@ -161,7 +231,7 @@ object EventSourcedStoreOf { .flatMap { seqNr => Sync[F].delay { - asyncWrite + asyncJournal .asyncWriteMessages(atomicWrites) .liftTo[F] .flatMap { results => @@ -180,7 +250,7 @@ object EventSourcedStoreOf { override def apply(seqNr: SeqNr): F[F[Unit]] = { Sync[F].delay { - asyncWrite + asyncJournal .asyncDeleteMessagesTo(id.value, seqNr) .liftTo[F] From 36f76fbb9a45a20c4641119f7c5ceaaca9919537 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 23 Nov 2023 18:53:01 +0100 Subject: [PATCH 09/29] wip: add basic EventSourcedActorOf test --- .../akkaeffect/persistence/Event.scala | 9 ++ .../persistence/EventSourcedStore.scala | 29 +++- .../akkaeffect/persistence/Recovery.scala | 14 ++ .../akkaeffect/persistence/Snapshot.scala | 7 + .../persistence/EventSourcedActorOf.scala | 7 +- .../persistence/EventSourcedStoreOf.scala | 24 +++- .../persistence/EventSourcedActorOfTest.scala | 134 ++++++++++++++++++ 7 files changed, 213 insertions(+), 11 deletions(-) create mode 100644 persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala index 7c11b79d..3247a464 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala @@ -6,3 +6,12 @@ trait Event[E] { def seqNr: SeqNr } + +object Event { + + def const[E](e: E, nr: SeqNr): Event[E] = new Event[E] { + override def event: E = e + override def seqNr: SeqNr = nr + } + +} diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 97589e36..6acfda18 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -1,5 +1,6 @@ package com.evolutiongaming.akkaeffect.persistence +import cats.Applicative import cats.effect.kernel.Resource /** @@ -25,7 +26,8 @@ trait EventSourcedStore[F[_], S, E] { * @param seqNr recovered [[SeqNr]] or [[SeqNr.Min]] * @return resource will be released upon actor termination */ - def journaller(id: EventSourcedId, seqNr: SeqNr): Resource[F, Journaller[F, E]] + def journaller(id: EventSourcedId, + seqNr: SeqNr): Resource[F, Journaller[F, E]] /** * Create [[Snapshotter]] capable of persisting and deleting snapshots @@ -34,3 +36,28 @@ trait EventSourcedStore[F[_], S, E] { */ def snapshotter(id: EventSourcedId): Resource[F, Snapshotter[F, S]] } + +object EventSourcedStore { + + def const[F[_]: Applicative, S, E]( + recovery_ : Recovery[F, S, E], + journaller_ : Journaller[F, E], + snapshotter_ : Snapshotter[F, S] + ): EventSourcedStore[F, S, E] = new EventSourcedStore[F, S, E] { + + import cats.syntax.all._ + + override def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] = + recovery_.pure[Resource[F, *]] + + override def journaller(id: EventSourcedId, + seqNr: SeqNr): Resource[F, Journaller[F, E]] = + journaller_.pure[Resource[F, *]] + + override def snapshotter( + id: EventSourcedId + ): Resource[F, Snapshotter[F, S]] = + snapshotter_.pure[Resource[F, *]] + } + +} diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala index 3274d267..5f557370 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala @@ -16,3 +16,17 @@ trait Recovery[F[_], S, E] { def events: Stream[F, Event[E]] } + +object Recovery { + + def const[F[_], S, E](_snapshot: Option[Snapshot[S]], + _events: Stream[F, Event[E]]): Recovery[F, S, E] = + new Recovery[F, S, E] { + + override def snapshot: Option[Snapshot[S]] = _snapshot + + override def events: Stream[F, Event[E]] = _events + + } + +} diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala index 3ffc67d4..5959b69f 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala @@ -13,4 +13,11 @@ object Snapshot { final case class Metadata(seqNr: SeqNr, timestamp: Instant) + def const[S](_snapshot: S, _metadata: Snapshot.Metadata): Snapshot[S] = + new Snapshot[S] { + + override def snapshot: S = _snapshot + + override def metadata: Metadata = _metadata + } } diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index 5f5c81a7..007d4ea7 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -29,14 +29,14 @@ object EventSourcedActorOf { def actor[F[_]: Async: ToFuture, S, E, C: ClassTag]( eventSourcedOf: EventSourcedOf[F, Lifecycle[F, S, E, C]], - eventSourcedStoreOf: EventSourcedStoreOf[F], + eventSourcedStoreOf: EventSourcedStoreOf[F, S, E], ): Actor = ActorOf[F] { actorCtx => for { eventSourced <- eventSourcedOf(actorCtx).toResource persistentId = eventSourced.eventSourcedId recoveryStarted <- eventSourced.value - store <- eventSourcedStoreOf[S, E](eventSourced) + store <- eventSourcedStoreOf(eventSourced) recovery <- store.recover(persistentId) recovering <- recoveryStarted( @@ -46,8 +46,7 @@ object EventSourcedActorOf { replaying = for { replay <- recovering.replay - events = recovery.events - seqNrL <- events + seqNrL <- recovery.events .foldWhileM(SeqNr.Min) { case (_, event) => replay(event.event, event.seqNr).as(event.seqNr.asLeft[Unit]) diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 1f03b4b9..3502064c 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -25,9 +25,9 @@ import java.time.Instant import scala.concurrent.Future import scala.util.Try -trait EventSourcedStoreOf[F[_]] { +trait EventSourcedStoreOf[F[_], S, E] { - def apply[S, E]( + def apply( eventSourced: EventSourced[?] ): Resource[F, EventSourcedStore[F, S, E]] @@ -35,9 +35,21 @@ trait EventSourcedStoreOf[F[_]] { object EventSourcedStoreOf { - def fromAkka[F[_]: Async: ToTry]( + def const[F[_], S, E]( + store: EventSourcedStore[F, S, E] + ): EventSourcedStoreOf[F, S, E] = + _ => store.pure[Resource[F, *]] + + /** + * Create [[EventSourcedStoreOf]] capable of creating [[EventSourcedStore]] + * on top of akka persistence and snapshot plugins defined in [[EventSourced.pluginIds]] + * @param system akka system + * @tparam F effect type + * @return instance of [[EventSourcedStoreOf]] + */ + def fromAkka[F[_]: Async: ToTry, S, E]( system: ExtendedActorSystem - ): F[EventSourcedStoreOf[F]] = { + ): F[EventSourcedStoreOf[F, S, E]] = { SerialRef[F] .of(Map.empty[String, Any]) @@ -61,9 +73,9 @@ object EventSourcedStoreOf { } } - new EventSourcedStoreOf[F] { + new EventSourcedStoreOf[F, S, E] { - override def apply[S, E]( + override def apply( eventSourced: EventSourced[_], ): Resource[F, EventSourcedStore[F, S, E]] = { val journalPath = eventSourced.pluginIds.journal getOrElse JournalFallbackConfigPath diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala new file mode 100644 index 00000000..1337d005 --- /dev/null +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala @@ -0,0 +1,134 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.actor.Props +import cats.effect.unsafe.implicits.global +import cats.effect.{IO, Ref, Resource} +import cats.syntax.all._ +import com.evolutiongaming.akkaeffect._ +import com.evolutiongaming.akkaeffect.persistence.InstrumentEventSourced.Action +import com.evolutiongaming.akkaeffect.testkit.Probe +import com.evolutiongaming.sstream.Stream +import org.scalatest.funsuite.AnyFunSuiteLike +import org.scalatest.matchers.should.Matchers + +import java.time.Instant +import scala.concurrent.duration._ + +class EventSourcedActorOfTest + extends AnyFunSuiteLike + with ActorSuite + with Matchers { + + type F[A] = IO[A] + + type S = Int + type E = String + type C = String + + type Receiving = Receive[F, Envelope[C], ActorOf.Stop] + type Lifecycle = Resource[F, RecoveryStarted[F, S, E, Receiving]] + + val actorRefOf = ActorRefOf.fromActorRefFactory[F](actorSystem) + + val timestamp = Instant.ofEpochMilli(0) // trust me, its for InstrumentEventSourced backward compatibility + val timeout = 1.second + + test("recover") { + + val id = EventSourcedId("id #1") + + val eventSourcedOf: EventSourcedOf[F, Lifecycle] = EventSourcedOf.const { + IO.delay { + EventSourced( + eventSourcedId = id, + value = RecoveryStarted + .const { + Recovering + .const[S] { + Replay.empty[F, E].pure[Resource[F, *]] + } { + Receive[Envelope[C]] { envelope => + val reply = + Reply.fromActorRef[F](to = envelope.from, from = none) + + envelope.msg match { + case "foo" => reply("bar").as(false) + case "bar" => reply("foo").as(false) + case "die" => IO.pure(true) + } + } { + true.pure[F] + }.pure[Resource[F, *]] + } + .pure[Resource[F, *]] + } + .pure[Resource[F, *]] + ) + } + } + + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + EventSourcedStore.const( + recovery_ = Recovery.const( + Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, + Stream.single(Event.const("first", 1L)) + ), + journaller_ = Journaller.empty[F, E], + snapshotter_ = Snapshotter.empty[F, S], + ) + } + + val actions = Ref.unsafe[F, List[Action[S, C, E]]](List.empty) + val esOf = InstrumentEventSourced(actions, eventSourcedOf) + def actor = EventSourcedActorOf.actor(esOf, eventSourcedStoreOf) + + Probe + .of(actorRefOf) + .use { probe => + for { + actor <- IO.delay { actorSystem.actorOf(Props(actor)) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + bar <- effect.ask("foo", timeout).flatten + _ = bar shouldEqual "bar" + + foo <- effect.ask("bar", timeout).flatten + _ = foo shouldEqual "foo" + + _ <- effect.tell("die") + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated( + 0L, + SnapshotOffer(SnapshotMetadata(0L, timestamp), 0).some + ), + Action.ReplayAllocated, + Action.Replayed("first", 1L), + Action.ReplayReleased, + Action.ReceiveAllocated(1L), + Action.Received("foo", null, stop = false), + Action.Received("bar", null, stop = false), + Action.Received("die", null, stop = true), + Action.ReceiveReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + } + .unsafeRunSync() + } + +} From d2490c392e041b5b5ea2e2f0cb6d6bb4f9e2a3e5 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 23 Nov 2023 19:45:12 +0100 Subject: [PATCH 10/29] wip: non-func improvement --- .../persistence/EventSourcedActorOfTest.scala | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala index 1337d005..4fb14a0e 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala @@ -79,15 +79,18 @@ class EventSourcedActorOfTest ) } - val actions = Ref.unsafe[F, List[Action[S, C, E]]](List.empty) - val esOf = InstrumentEventSourced(actions, eventSourcedOf) - def actor = EventSourcedActorOf.actor(esOf, eventSourcedStoreOf) - Probe .of(actorRefOf) .use { probe => for { - actor <- IO.delay { actorSystem.actorOf(Props(actor)) } + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- IO.delay { actorSystem.actorOf(props) } effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) From 2bf7580375ea43935a482b28730de21c14cadb35 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Mon, 27 Nov 2023 15:36:47 +0100 Subject: [PATCH 11/29] wip: add more tests --- .../persistence/EventSourcedStore.scala | 33 +- .../persistence/EventSourcedActorOfTest.scala | 561 +++++++++++++++--- 2 files changed, 496 insertions(+), 98 deletions(-) diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 6acfda18..1df2ffef 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -40,24 +40,29 @@ trait EventSourcedStore[F[_], S, E] { object EventSourcedStore { def const[F[_]: Applicative, S, E]( - recovery_ : Recovery[F, S, E], - journaller_ : Journaller[F, E], - snapshotter_ : Snapshotter[F, S] - ): EventSourcedStore[F, S, E] = new EventSourcedStore[F, S, E] { + recovery: Recovery[F, S, E], + journaller: Journaller[F, E], + snapshotter: Snapshotter[F, S] + ): EventSourcedStore[F, S, E] = { - import cats.syntax.all._ + val (r, j, s) = (recovery, journaller, snapshotter) - override def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] = - recovery_.pure[Resource[F, *]] + new EventSourcedStore[F, S, E] { - override def journaller(id: EventSourcedId, - seqNr: SeqNr): Resource[F, Journaller[F, E]] = - journaller_.pure[Resource[F, *]] + import cats.syntax.all._ - override def snapshotter( - id: EventSourcedId - ): Resource[F, Snapshotter[F, S]] = - snapshotter_.pure[Resource[F, *]] + override def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] = + r.pure[Resource[F, *]] + + override def journaller(id: EventSourcedId, + seqNr: SeqNr): Resource[F, Journaller[F, E]] = + j.pure[Resource[F, *]] + + override def snapshotter( + id: EventSourcedId + ): Resource[F, Snapshotter[F, S]] = + s.pure[Resource[F, *]] + } } } diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala index 4fb14a0e..6b7934cc 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala @@ -1,25 +1,27 @@ package com.evolutiongaming.akkaeffect.persistence import akka.actor.Props +import cats.effect.implicits.effectResourceOps import cats.effect.unsafe.implicits.global -import cats.effect.{IO, Ref, Resource} +import cats.effect.{Async, IO, Ref, Resource} import cats.syntax.all._ import com.evolutiongaming.akkaeffect._ import com.evolutiongaming.akkaeffect.persistence.InstrumentEventSourced.Action import com.evolutiongaming.akkaeffect.testkit.Probe import com.evolutiongaming.sstream.Stream -import org.scalatest.funsuite.AnyFunSuiteLike import org.scalatest.matchers.should.Matchers +import org.scalatest.wordspec.AnyWordSpec import java.time.Instant import scala.concurrent.duration._ class EventSourcedActorOfTest - extends AnyFunSuiteLike + extends AnyWordSpec with ActorSuite with Matchers { type F[A] = IO[A] + val F = Async[F] type S = Int type E = String @@ -30,108 +32,499 @@ class EventSourcedActorOfTest val actorRefOf = ActorRefOf.fromActorRefFactory[F](actorSystem) - val timestamp = Instant.ofEpochMilli(0) // trust me, its for InstrumentEventSourced backward compatibility + val timestamp = Instant.ofEpochMilli(0) // due to hardcoded value in InstrumentEventSourced #45 val timeout = 1.second - test("recover") { + "recover" when { val id = EventSourcedId("id #1") - val eventSourcedOf: EventSourcedOf[F, Lifecycle] = EventSourcedOf.const { - IO.delay { - EventSourced( - eventSourcedId = id, - value = RecoveryStarted - .const { - Recovering - .const[S] { - Replay.empty[F, E].pure[Resource[F, *]] - } { - Receive[Envelope[C]] { envelope => - val reply = - Reply.fromActorRef[F](to = envelope.from, from = none) - - envelope.msg match { - case "foo" => reply("bar").as(false) - case "bar" => reply("foo").as(false) - case "die" => IO.pure(true) - } + val eventSourcedOf: EventSourcedOf[F, Lifecycle] = + EventSourcedOf.const { + F.delay { + EventSourced( + eventSourcedId = id, + value = RecoveryStarted + .const { + Recovering + .const[S] { + Replay.empty[F, E].pure[Resource[F, *]] } { - true.pure[F] - }.pure[Resource[F, *]] + Receive[Envelope[C]] { envelope => + val reply = + Reply.fromActorRef[F](to = envelope.from, from = none) + + envelope.msg match { + case "foo" => reply("bar").as(false) + case "bar" => reply("foo").as(false) + case "die" => F.pure(true) + } + } { + true.pure[F] + }.pure[Resource[F, *]] + } + .pure[Resource[F, *]] + } + .pure[Resource[F, *]] + ) + } + } + + "no snapshots and no events" should { + + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + EventSourcedStore.const( + recovery = Recovery.const(none, Stream.empty), + journaller = Journaller.empty[F, E], + snapshotter = Snapshotter.empty[F, S], + ) + } + + "recover empty state" in { + + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action } - .pure[Resource[F, *]] - } - .pure[Resource[F, *]] - ) + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated(0L, none), + Action.ReplayAllocated, + Action.ReplayReleased, + Action.ReceiveAllocated(0L), + Action.Received(akka.actor.ReceiveTimeout, null, stop = true), + Action.ReceiveReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + } } } - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - EventSourcedStore.const( - recovery_ = Recovery.const( - Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, - Stream.single(Event.const("first", 1L)) - ), - journaller_ = Journaller.empty[F, E], - snapshotter_ = Snapshotter.empty[F, S], - ) + "no snapshots and few events" should { + + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + EventSourcedStore.const( + recovery = Recovery.const( + none, + Stream.from[F, Seq, Event[E]]( + Seq(Event.const("first", 1L), Event.const("second", 2L)) + ) + ), + journaller = Journaller.empty[F, E], + snapshotter = Snapshotter.empty[F, S], + ) + } + + "recover empty state" in { + + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated(0L, none), + Action.ReplayAllocated, + Action.Replayed("first", 1L), + Action.Replayed("second", 2L), + Action.ReplayReleased, + Action.ReceiveAllocated(0L), + Action.Received(akka.actor.ReceiveTimeout, null, stop = true), + Action.ReceiveReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + } } + } - Probe - .of(actorRefOf) - .use { probe => - for { - actions <- Ref[F].of(List.empty[Action[S, C, E]]) - eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) - .pure[F] + "there's snapshot and no events" should { - props = Props( - EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + EventSourcedStore.const( + recovery = Recovery.const( + Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, + Stream.empty + ), + journaller = Journaller.empty[F, E], + snapshotter = Snapshotter.empty[F, S], ) - actor <- IO.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] - terminated <- probe.watch(actor) + } - bar <- effect.ask("foo", timeout).flatten - _ = bar shouldEqual "bar" + "recover empty state" in { - foo <- effect.ask("bar", timeout).flatten - _ = foo shouldEqual "foo" + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] - _ <- effect.tell("die") - _ <- terminated + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) - actions <- actions.get - actions <- actions - .map { - case Action.Received(m, _, s) => Action.Received(m, null, s) - case action => action - } - .reverse - .pure[F] - _ = actions shouldEqual List( - Action.Created(id, akka.persistence.Recovery(), PluginIds()), - Action.Started, - Action.RecoveryAllocated( - 0L, - SnapshotOffer(SnapshotMetadata(0L, timestamp), 0).some + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated( + 0L, + SnapshotOffer(SnapshotMetadata(0L, timestamp), 0).some + ), + Action.ReplayAllocated, + Action.ReplayReleased, + Action.ReceiveAllocated(0L), + Action.Received(akka.actor.ReceiveTimeout, null, stop = true), + Action.ReceiveReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + } + } + } + + "there's snapshot and few event" should { + + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + EventSourcedStore.const( + recovery = Recovery.const( + Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, + Stream.from[F, Seq, Event[E]]( + Seq(Event.const("first", 1L), Event.const("second", 2L)) + ) ), - Action.ReplayAllocated, - Action.Replayed("first", 1L), - Action.ReplayReleased, - Action.ReceiveAllocated(1L), - Action.Received("foo", null, stop = false), - Action.Received("bar", null, stop = false), - Action.Received("die", null, stop = true), - Action.ReceiveReleased, - Action.RecoveryReleased, - Action.Released + journaller = Journaller.empty[F, E], + snapshotter = Snapshotter.empty[F, S], ) - } yield {} + } + + "be successful" in { + + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + bar <- effect.ask("foo", timeout).flatten + _ = bar shouldEqual "bar" + + foo <- effect.ask("bar", timeout).flatten + _ = foo shouldEqual "foo" + + _ <- effect.tell("die") + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated( + 0L, + SnapshotOffer(SnapshotMetadata(0L, timestamp), 0).some + ), + Action.ReplayAllocated, + Action.Replayed("first", 1L), + Action.Replayed("second", 2L), + Action.ReplayReleased, + Action.ReceiveAllocated(2L), + Action.Received("foo", null, stop = false), + Action.Received("bar", null, stop = false), + Action.Received("die", null, stop = true), + Action.ReceiveReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + } + } - .unsafeRunSync() + + "stop on akka.actor.ReceiveTimeout" in { + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated( + 0L, + SnapshotOffer(SnapshotMetadata(0L, timestamp), 0).some + ), + Action.ReplayAllocated, + Action.Replayed("first", 1L), + Action.Replayed("second", 2L), + Action.ReplayReleased, + Action.ReceiveAllocated(2L), + Action.ReceiveTimeout, + Action.ReceiveReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + + } + .unsafeRunSync() + } + + } + + "exception" should { + + "be raised from EventSourcedStoreOf[F].apply, ie on loading Akka plugins in EventSourcedStoreOf.fromAkka" in { + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + _ => F.raiseError(new RuntimeException()).toResource + + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.Released + ) + } yield {} + + } + .unsafeRunSync() + } + + "be raised from EventSourcedStore[F].recover, ie on loading snapshot" in { + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + new EventSourcedStore[F, S, E] { + override def recover(id: EventSourcedId) = + F.raiseError(new RuntimeException()).toResource + + override def journaller(id: EventSourcedId, seqNr: SeqNr) = + Journaller.empty[F, E].pure[Resource[F, *]] + + override def snapshotter(id: EventSourcedId) = + Snapshotter.empty[F, S].pure[Resource[F, *]] + } + } + + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.Released + ) + } yield {} + + } + .unsafeRunSync() + } + + "be raised on materialisation of Stream provided by Recovery[F].events', ie on loading events" in { + val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = + EventSourcedStoreOf.const { + EventSourcedStore.const( + recovery = Recovery + .const(none, Stream.lift(F.raiseError(new RuntimeException()))), + journaller = Journaller.empty[F, E], + snapshotter = Snapshotter.empty[F, S] + ) + } + + Probe + .of(actorRefOf) + .use { probe => + for { + actions <- Ref[F].of(List.empty[Action[S, C, E]]) + eventSourcedOf <- InstrumentEventSourced(actions, eventSourcedOf) + .pure[F] + + props = Props( + EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) + ) + actor <- F.delay { actorSystem.actorOf(props) } + effect <- ActorEffect.fromActor[F](actor).pure[F] + terminated <- probe.watch(actor) + + _ <- effect.tell(akka.actor.ReceiveTimeout) + _ <- terminated + + actions <- actions.get + actions <- actions + .map { + case Action.Received(m, _, s) => Action.Received(m, null, s) + case action => action + } + .reverse + .pure[F] + _ = actions shouldEqual List( + Action.Created(id, akka.persistence.Recovery(), PluginIds()), + Action.Started, + Action.RecoveryAllocated(0L, none), + Action.ReplayAllocated, + Action.ReplayReleased, + Action.RecoveryReleased, + Action.Released + ) + } yield {} + + } + .unsafeRunSync() + } + + } } } From 16e0b8837ed40a5e15b6554f0ec2155e343a52e7 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Mon, 27 Nov 2023 15:49:21 +0100 Subject: [PATCH 12/29] wip: cleanup non-related changes --- .../com/evolutiongaming/akkaeffect/Act.scala | 8 -- .../evolutiongaming/akkaeffect/ActorOf.scala | 5 +- .../evolutiongaming/akkaeffect/ActorVar.scala | 4 +- .../persistence/DeleteEventsTo.scala | 35 +++--- .../persistence/EventSourcedStore.scala | 3 +- .../akkaeffect/persistence/Journaller.scala | 37 +++++-- .../persistence/PersistentActorOf.scala | 100 ++++++------------ 7 files changed, 82 insertions(+), 110 deletions(-) diff --git a/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala b/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala index 881789a2..c46e4c42 100644 --- a/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala +++ b/actor/src/main/scala/com/evolutiongaming/akkaeffect/Act.scala @@ -106,14 +106,6 @@ private[akkaeffect] object Act { } } - /** - * set thread local [[threadLocal]] to [[self]] and - * if message is of type [[Msg]] - apply internal function, - * otherwise delegate receive to [[receive]] - * - * @param receive [[Actor.Receive]] partial function - * @return [[Actor.Receive]] partial function: Any => Unit - */ def receive(receive: Actor.Receive): Actor.Receive = { val receiveMsg: Actor.Receive = { case Msg(f) => f() } syncReceive(receiveMsg orElse receive) diff --git a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala index 2efa1969..7309e1f2 100644 --- a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala +++ b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorOf.scala @@ -23,15 +23,14 @@ object ActorOf { type State = Receive[F, Envelope[Any], Stop] - def onPreStart(actorCtx: ActorCtx[F])(implicit fail: Fail[F]): Resource[F, Receive[F, Envelope[Any], Stop]] = { + def onPreStart(actorCtx: ActorCtx[F])(implicit fail: Fail[F]) = { receiveOf(actorCtx) .handleErrorWith { (error: Throwable) => s"failed to allocate receive".fail[F, State](error).toResource } } - // a - message - def onReceive(a: Any, sender: ActorRef)(implicit fail: Fail[F]): State => F[Directive[Releasable[F, State]]] = { + def onReceive(a: Any, sender: ActorRef)(implicit fail: Fail[F]) = { state: State => val stop = a match { case ReceiveTimeout => state.timeout diff --git a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala index 0d48ab6f..94457d6d 100644 --- a/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala +++ b/actor/src/main/scala/com/evolutiongaming/akkaeffect/ActorVar.scala @@ -73,8 +73,6 @@ private[akkaeffect] object ActorVar { new ActorVar[F, A] { - // A - actor' state - def preStart(resource: Resource[F, A]) = { update { _ => resource @@ -83,7 +81,7 @@ private[akkaeffect] object ActorVar { } } - def receive(f: A => F[Directive[Releasable[F, A]]]): Unit = { + def receive(f: A => F[Directive[Releasable[F, A]]]) = { update { case Some(state) => f(state.value).flatMap { diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala index bd02889c..49f1d231 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/DeleteEventsTo.scala @@ -5,6 +5,9 @@ import cats.{Applicative, FlatMap, ~>} import com.evolutiongaming.akkaeffect.Fail import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable} +/** + * @see [[akka.persistence.Eventsourced.deleteMessages]] + */ trait DeleteEventsTo[F[_]] { /** @@ -30,19 +33,17 @@ object DeleteEventsTo { private sealed abstract class MapK - implicit class DeleteEventsToOps[F[_]](val self: DeleteEventsTo[F]) - extends AnyVal { + + implicit class DeleteEventsToOps[F[_]](val self: DeleteEventsTo[F]) extends AnyVal { def mapK[G[_]: Applicative](f: F ~> G): DeleteEventsTo[G] = { new MapK with DeleteEventsTo[G] { - def apply(seqNr: SeqNr) = f(self(seqNr)).map { a => - f(a) - } + def apply(seqNr: SeqNr) = f(self(seqNr)).map { a => f(a) } } } - def withLogging1(log: Log[F])( - implicit + def withLogging1( + log: Log[F])(implicit F: FlatMap[F], measureDuration: MeasureDuration[F] ): DeleteEventsTo[F] = { @@ -51,18 +52,19 @@ object DeleteEventsTo { for { d <- MeasureDuration[F].start r <- self(seqNr) - } yield - for { - r <- r - d <- d - _ <- log.info(s"delete events to $seqNr in ${d.toMillis}ms") - } yield r + } yield for { + r <- r + d <- d + _ <- log.info(s"delete events to $seqNr in ${ d.toMillis }ms") + } yield r } } } - def withFail(fail: Fail[F])(implicit - F: MonadThrowable[F]): DeleteEventsTo[F] = { + def withFail( + fail: Fail[F])(implicit + F: MonadThrowable[F] + ): DeleteEventsTo[F] = { new WithFail with DeleteEventsTo[F] { def apply(seqNr: SeqNr) = { @@ -73,5 +75,4 @@ object DeleteEventsTo { } } } - -} +} \ No newline at end of file diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 1df2ffef..60765790 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -1,6 +1,5 @@ package com.evolutiongaming.akkaeffect.persistence -import cats.Applicative import cats.effect.kernel.Resource /** @@ -39,7 +38,7 @@ trait EventSourcedStore[F[_], S, E] { object EventSourcedStore { - def const[F[_]: Applicative, S, E]( + def const[F[_], S, E]( recovery: Recovery[F, S, E], journaller: Journaller[F, E], snapshotter: Snapshotter[F, S] diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala index 98cbbaad..a6192237 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Journaller.scala @@ -11,19 +11,30 @@ import com.evolutiongaming.catshelper.{Log, MeasureDuration, MonadThrowable} */ trait Journaller[F[_], -A] { + /** + * @see [[akka.persistence.PersistentActor.persistAllAsync]] + */ def append: Append[F, A] + /** + * @see [[akka.persistence.Eventsourced.deleteMessages]] + * @return outer F[_] is about deletion in background, inner F[_] is about deletion being completed + */ def deleteTo: DeleteEventsTo[F] } + object Journaller { def empty[F[_]: Applicative, A]: Journaller[F, A] = { Journaller(Append.empty[F, A], DeleteEventsTo.empty[F]) } - def apply[F[_], A](append: Append[F, A], - deleteEventsTo: DeleteEventsTo[F]): Journaller[F, A] = { + + def apply[F[_], A]( + append: Append[F, A], + deleteEventsTo: DeleteEventsTo[F] + ): Journaller[F, A] = { val append1 = append @@ -36,6 +47,7 @@ object Journaller { } } + private sealed abstract class Narrow private sealed abstract class Convert @@ -44,8 +56,8 @@ object Journaller { private sealed abstract class WithFail - implicit class JournallerOps[F[_], A](val self: Journaller[F, A]) - extends AnyVal { + + implicit class JournallerOps[F[_], A](val self: Journaller[F, A]) extends AnyVal { def mapK[G[_]: Applicative](f: F ~> G): Journaller[G, A] = { new MapK with Journaller[G, A] { @@ -56,6 +68,7 @@ object Journaller { } } + def convert[B](f: B => F[A])(implicit F: Monad[F]): Journaller[F, B] = { new Convert with Journaller[F, B] { @@ -65,6 +78,7 @@ object Journaller { } } + def narrow[B <: A]: Journaller[F, B] = { new Narrow with Journaller[F, B] { @@ -74,17 +88,18 @@ object Journaller { } } - def withLogging1(log: Log[F])( - implicit + def withLogging1( + log: Log[F])(implicit F: FlatMap[F], measureDuration: MeasureDuration[F] ): Journaller[F, A] = { - Journaller(self.append.withLogging1(log), self.deleteTo.withLogging1(log)) + Journaller( + self.append.withLogging1(log), + self.deleteTo.withLogging1(log)) } - def withFail( - fail: Fail[F] - )(implicit F: MonadThrowable[F]): Journaller[F, A] = { + + def withFail(fail: Fail[F])(implicit F: MonadThrowable[F]): Journaller[F, A] = { new WithFail with Journaller[F, A] { val append = self.append.withFail(fail) @@ -93,4 +108,4 @@ object Journaller { } } } -} +} \ No newline at end of file diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala index 97b9e2f5..846d2faf 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/PersistentActorOf.scala @@ -14,6 +14,7 @@ import com.evolutiongaming.catshelper.{FromFuture, Memoize, ToFuture, ToTry} import scala.collection.immutable.Seq import scala.concurrent.duration._ + object PersistentActorOf { /** @@ -29,10 +30,7 @@ object PersistentActorOf { * @tparam E event * @tparam C command */ - type Type[F[_], S, E, C] = EventSourcedOf[ - F, - Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]] - ] + type Type[F[_], S, E, C] = EventSourcedOf[F, Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]]] def apply[F[_]: Async: ToFuture: FromFuture: ToTry]( eventSourcedOf: Type[F, Any, Any, Any], @@ -43,19 +41,13 @@ object PersistentActorOf { lazy val (actorCtx, act, eventSourced) = { val actorCtxRef = AtomicRef(ActorCtx[F](context)) - val actorCtx = ActorCtx.flatten(context, Sync[F].delay { - actorCtxRef.get() - }) + val actorCtx = ActorCtx.flatten(context, Sync[F].delay { actorCtxRef.get() }) val act = Act.Adapter(context.self) val eventSourced = act.sync { eventSourcedOf(actorCtx) - .adaptError { - case error => - val path = self.path.toStringWithoutAddress - ActorError( - s"$path failed to allocate eventSourced: $error", - error - ) + .adaptError { case error => + val path = self.path.toStringWithoutAddress + ActorError(s"$path failed to allocate eventSourced: $error", error) } .toTry .get @@ -63,12 +55,9 @@ object PersistentActorOf { (actorCtxRef, act, eventSourced) } - private def actorError(msg: String, - cause: Option[Throwable]): Throwable = { + private def actorError(msg: String, cause: Option[Throwable]): Throwable = { val path = self.path.toStringWithoutAddress - val causeStr: String = cause.foldMap { a => - s": $a" - } + val causeStr: String = cause.foldMap { a => s": $a" } ActorError(s"$path $persistenceId $msg$causeStr", cause) } @@ -78,26 +67,27 @@ object PersistentActorOf { } } - case class Resources(append: AppendOf.Adapter[F, Any], - deleteEventsTo: DeleteEventsTo[F]) + case class Resources( + append: AppendOf.Adapter[F, Any], + deleteEventsTo: DeleteEventsTo[F]) lazy val (resources: Resources, release) = { - val stopped = Memoize.sync[F, Throwable] { - Sync[F].delay { actorError("has been stopped", none) } - } + val stopped = Memoize.sync[F, Throwable] { Sync[F].delay { actorError("has been stopped", none) } } val result = for { - stopped <- stopped.toResource - act <- act.value.pure[Resource[F, *]] - append <- AppendOf.adapter[F, Any](act, actor, stopped) + stopped <- stopped.toResource + act <- act.value.pure[Resource[F, *]] + append <- AppendOf.adapter[F, Any](act, actor, stopped) deleteEventsTo <- DeleteEventsToOf.of(actor, timeout) } yield { Resources(append, deleteEventsTo) } - result.allocated.toTry.get + result + .allocated + .toTry + .get } - val persistence: PersistenceVar[F, Any, Any, Any] = - PersistenceVar[F, Any, Any, Any](act.value, context) + val persistence: PersistenceVar[F, Any, Any, Any] = PersistenceVar[F, Any, Any, Any](act.value, context) override def preStart(): Unit = { super.preStart() @@ -117,22 +107,16 @@ object PersistentActorOf { eventSourced.pluginIds.snapshot getOrElse super.snapshotPluginId } - override protected def onPersistFailure(cause: Throwable, - event: Any, - seqNr: Long) = { - val error = - actorError(s"[$seqNr] persist failed for $event", cause.some) + override protected def onPersistFailure(cause: Throwable, event: Any, seqNr: Long) = { + val error = actorError(s"[$seqNr] persist failed for $event", cause.some) act.sync { resources.append.onError(error, event, seqNr) } super.onPersistFailure(cause, event, seqNr) } - override protected def onPersistRejected(cause: Throwable, - event: Any, - seqNr: Long) = { - val error = - actorError(s"[$seqNr] persist rejected for $event", cause.some) + override protected def onPersistRejected(cause: Throwable, event: Any, seqNr: Long) = { + val error = actorError(s"[$seqNr] persist rejected for $event", cause.some) act.sync { resources.append.onError(error, event, seqNr) } @@ -140,11 +124,9 @@ object PersistentActorOf { } def receiveRecover: Receive = act.receive { - case ap.SnapshotOffer(m, s) => - persistence - .snapshotOffer(lastSeqNr(), SnapshotOffer(SnapshotMetadata(m), s)) - case RecoveryCompleted => recoveryCompleted(lastSeqNr()) - case event => persistence.event(lastSeqNr(), event) + case ap.SnapshotOffer(m, s) => persistence.snapshotOffer(lastSeqNr(), SnapshotOffer(SnapshotMetadata(m), s)) + case RecoveryCompleted => recoveryCompleted(lastSeqNr()) + case event => persistence.event(lastSeqNr(), event) } def receiveCommand: Receive = act.receive { @@ -153,39 +135,27 @@ object PersistentActorOf { } override def persist[A](event: A)(f: A => Unit): Unit = { - super.persist(event) { a => - act.sync { f(a) } - } + super.persist(event) { a => act.sync { f(a) } } } override def persistAll[A](events: Seq[A])(f: A => Unit): Unit = { - super.persistAll(events) { a => - act.sync { f(a) } - } + super.persistAll(events) { a => act.sync { f(a) } } } override def persistAsync[A](event: A)(f: A => Unit): Unit = { - super.persistAsync(event) { a => - act.sync { f(a) } - } + super.persistAsync(event) { a => act.sync { f(a) } } } override def persistAllAsync[A](events: Seq[A])(f: A => Unit) = { - super.persistAllAsync(events) { a => - act.sync { f(a) } - } + super.persistAllAsync(events) { a => act.sync { f(a) } } } override def defer[A](event: A)(f: A => Unit): Unit = { - super.defer(event) { a => - act.sync { f(a) } - } + super.defer(event) { a => act.sync { f(a) } } } override def deferAsync[A](event: A)(f: A => Unit): Unit = { - super.deferAsync(event) { a => - act.sync { f(a) } - } + super.deferAsync(event) { a => act.sync { f(a) } } } override def postStop() = { @@ -201,9 +171,7 @@ object PersistentActorOf { } private def recoveryCompleted(seqNr: SeqNr): Unit = { - val journaller = - Journaller[F, Any](resources.append.value, resources.deleteEventsTo) - .withFail(fail) + val journaller = Journaller[F, Any](resources.append.value, resources.deleteEventsTo).withFail(fail) val snapshotter = SnapshotterOf[F, Any](actor, timeout).withFail(fail) persistence.recoveryCompleted(seqNr, journaller, snapshotter) } From 421f89c7d70e7ca2a802821f9b392768fea1d689 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Mon, 27 Nov 2023 16:01:34 +0100 Subject: [PATCH 13/29] wip: fix compilation of 2.12 --- .../persistence/EventSourcedStore.scala | 36 +++++++++++++++++++ .../akkaeffect/persistence/Recovery.scala | 32 ----------------- .../persistence/EventSourcedStoreOf.scala | 4 +-- .../persistence/EventSourcedActorOfTest.scala | 18 +++++----- 4 files changed, 47 insertions(+), 43 deletions(-) delete mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 60765790..18096830 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -1,6 +1,7 @@ package com.evolutiongaming.akkaeffect.persistence import cats.effect.kernel.Resource +import com.evolutiongaming.sstream.Stream /** * Event sourcing persistence API: provides snapshot followed by stream of events @@ -11,6 +12,8 @@ import cats.effect.kernel.Resource */ trait EventSourcedStore[F[_], S, E] { + import EventSourcedStore.Recovery + /** * Start recovery by retrieving snapshot (eager, happening on resource allocation) * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) @@ -38,6 +41,39 @@ trait EventSourcedStore[F[_], S, E] { object EventSourcedStore { + /** + * Representation of __started__ recovery process: + * snapshot is already loaded in memory (if any) + * while events will be loaded only on materialisation of [[Stream]] + * + * @tparam F effect + * @tparam S snapshot + * @tparam E event + */ + trait Recovery[F[_], S, E] { + + def snapshot: Option[Snapshot[S]] + def events: Stream[F, Event[E]] + + } + + object Recovery { + + def const[F[_], S, E](snapshot: Option[Snapshot[S]], + events: Stream[F, Event[E]]): Recovery[F, S, E] = { + + val (s, e) = (snapshot, events) + + new Recovery[F, S, E] { + + override def snapshot: Option[Snapshot[S]] = s + override def events: Stream[F, Event[E]] = e + + } + } + + } + def const[F[_], S, E]( recovery: Recovery[F, S, E], journaller: Journaller[F, E], diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala deleted file mode 100644 index 5f557370..00000000 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Recovery.scala +++ /dev/null @@ -1,32 +0,0 @@ -package com.evolutiongaming.akkaeffect.persistence - -import com.evolutiongaming.sstream.Stream - -/** - * Representation of __started__ recovery process: - * snapshot is already loaded in memory (if any) - * while events will be loaded only on materialisation of [[Stream]] - * @tparam F effect - * @tparam S snapshot - * @tparam E event - */ -trait Recovery[F[_], S, E] { - - def snapshot: Option[Snapshot[S]] - def events: Stream[F, Event[E]] - -} - -object Recovery { - - def const[F[_], S, E](_snapshot: Option[Snapshot[S]], - _events: Stream[F, Event[E]]): Recovery[F, S, E] = - new Recovery[F, S, E] { - - override def snapshot: Option[Snapshot[S]] = _snapshot - - override def events: Stream[F, Event[E]] = _events - - } - -} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 3502064c..02067807 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -123,14 +123,14 @@ object EventSourcedStoreOf { override def recover( id: EventSourcedId - ): Resource[F, Recovery[F, S, E]] = { + ): Resource[F, EventSourcedStore.Recovery[F, S, E]] = { snapshotStore .loadAsync(id.value, SnapshotSelectionCriteria()) .liftTo[F] .toResource .map { offer => - new Recovery[F, S, E] { + new EventSourcedStore.Recovery[F, S, E] { override val snapshot: Option[Snapshot[S]] = offer.map { offer => diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala index 6b7934cc..22e512fa 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala @@ -75,7 +75,7 @@ class EventSourcedActorOfTest val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = EventSourcedStoreOf.const { EventSourcedStore.const( - recovery = Recovery.const(none, Stream.empty), + recovery = EventSourcedStore.Recovery.const(none, Stream.empty), journaller = Journaller.empty[F, E], snapshotter = Snapshotter.empty[F, S], ) @@ -131,10 +131,10 @@ class EventSourcedActorOfTest val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = EventSourcedStoreOf.const { EventSourcedStore.const( - recovery = Recovery.const( + recovery = EventSourcedStore.Recovery.const( none, - Stream.from[F, Seq, Event[E]]( - Seq(Event.const("first", 1L), Event.const("second", 2L)) + Stream.from[F, List, Event[E]]( + List(Event.const("first", 1L), Event.const("second", 2L)) ) ), journaller = Journaller.empty[F, E], @@ -194,7 +194,7 @@ class EventSourcedActorOfTest val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = EventSourcedStoreOf.const { EventSourcedStore.const( - recovery = Recovery.const( + recovery = EventSourcedStore.Recovery.const( Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, Stream.empty ), @@ -256,10 +256,10 @@ class EventSourcedActorOfTest val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = EventSourcedStoreOf.const { EventSourcedStore.const( - recovery = Recovery.const( + recovery = EventSourcedStore.Recovery.const( Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, - Stream.from[F, Seq, Event[E]]( - Seq(Event.const("first", 1L), Event.const("second", 2L)) + Stream.from[F, List, Event[E]]( + List(Event.const("first", 1L), Event.const("second", 2L)) ) ), journaller = Journaller.empty[F, E], @@ -476,7 +476,7 @@ class EventSourcedActorOfTest val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = EventSourcedStoreOf.const { EventSourcedStore.const( - recovery = Recovery + recovery = EventSourcedStore.Recovery .const(none, Stream.lift(F.raiseError(new RuntimeException()))), journaller = Journaller.empty[F, E], snapshotter = Snapshotter.empty[F, S] From 878a64fa6ee8e0477513a10376feb125d0634389 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 28 Nov 2023 13:55:41 +0100 Subject: [PATCH 14/29] wip --- .../scala/akka/persistence/PluginLoader.scala | 43 ------------ .../persistence/EventSourcedActorOf.scala | 28 ++++---- .../persistence/EventSourcedStoreOf.scala | 67 ++----------------- .../persistence/EventSourcedStoreOfTest.scala | 48 +++++++++++++ 4 files changed, 65 insertions(+), 121 deletions(-) delete mode 100644 persistence/src/main/scala/akka/persistence/PluginLoader.scala create mode 100644 persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala diff --git a/persistence/src/main/scala/akka/persistence/PluginLoader.scala b/persistence/src/main/scala/akka/persistence/PluginLoader.scala deleted file mode 100644 index 454bf966..00000000 --- a/persistence/src/main/scala/akka/persistence/PluginLoader.scala +++ /dev/null @@ -1,43 +0,0 @@ -package akka.persistence - -import akka.actor.ExtendedActorSystem -import akka.util.Reflect - -import scala.reflect.ClassTag -import scala.util.control.NonFatal - -object PluginLoader { - - /** - * Instantiate plugin of type [[T]] configured by path [[configPath]] - * @param system akka system - * @param configPath plugin config path - * @tparam T plugin type - * @return instance of plugin or thrown [[Exception]] - */ - def loadPlugin[T: ClassTag](system: ExtendedActorSystem, - configPath: String): T = { - - val config = system.settings.config.getConfig(configPath) - val className = config.getString("class") - - if (className.isEmpty) - throw new IllegalArgumentException( - s"Plugin class name must be defined in config property [$configPath.class]" - ) - system.log.debug(s"Create plugin: $configPath $className") - - val clazz = - system.dynamicAccess.getClassFor[T](className).get.asInstanceOf[Class[T]] - - try Reflect.instantiate[T](clazz, List(config, configPath)) - catch { - case NonFatal(_) => - try Reflect.instantiate[T](clazz, List(config)) - catch { - case NonFatal(_) => Reflect.instantiate[T](clazz, List.empty) - } - } - } - -} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index 007d4ea7..440f381c 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -44,22 +44,20 @@ object EventSourcedActorOf { recovery.snapshot.map(_.asOffer) ) - replaying = for { - replay <- recovering.replay - seqNrL <- recovery.events - .foldWhileM(SeqNr.Min) { - case (_, event) => - replay(event.event, event.seqNr).as(event.seqNr.asLeft[Unit]) - } - .toResource - seqNr <- seqNrL - .as(new IllegalStateException("should newer happened")) - .swap - .liftTo[F] - .toResource - } yield seqNr + seqNr <- recovering.replay.use { replay => + for { + seqNrL <- recovery.events + .foldWhileM(SeqNr.Min) { + case (_, event) => + replay(event.event, event.seqNr).as(event.seqNr.asLeft[Unit]) + } + seqNr <- seqNrL + .as(new IllegalStateException("should newer happened")) + .swap + .liftTo[F] + } yield seqNr + }.toResource - seqNr <- replaying.use(_.pure[F]).toResource journaller <- store.journaller(persistentId, seqNr) snapshotter <- store.snapshotter(persistentId) receive <- recovering.completed(seqNr, journaller, snapshotter) diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala index 02067807..9ab33458 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala @@ -1,23 +1,13 @@ package com.evolutiongaming.akkaeffect.persistence import akka.actor.ExtendedActorSystem -import akka.persistence.Persistence.{ - JournalFallbackConfigPath, - SnapshotStoreFallbackConfigPath -} import akka.persistence.journal.AsyncWriteJournal import akka.persistence.snapshot.SnapshotStore -import akka.persistence.{ - AtomicWrite, - PersistentRepr, - PluginLoader, - SnapshotSelectionCriteria -} -import cats.Applicative +import akka.persistence.{AtomicWrite, PersistentRepr, SnapshotSelectionCriteria} import cats.effect._ import cats.effect.implicits.effectResourceOps import cats.syntax.all._ -import com.evolutiongaming.catshelper.{FromFuture, SerialRef, ToTry} +import com.evolutiongaming.catshelper.{FromFuture, ToTry} import com.evolutiongaming.sstream.FoldWhile._ import com.evolutiongaming.sstream.Stream @@ -28,7 +18,7 @@ import scala.util.Try trait EventSourcedStoreOf[F[_], S, E] { def apply( - eventSourced: EventSourced[?] + eventSourced: EventSourced[_] ): Resource[F, EventSourcedStore[F, S, E]] } @@ -49,56 +39,7 @@ object EventSourcedStoreOf { */ def fromAkka[F[_]: Async: ToTry, S, E]( system: ExtendedActorSystem - ): F[EventSourcedStoreOf[F, S, E]] = { - - SerialRef[F] - .of(Map.empty[String, Any]) - .map { cache => - def cached[A](id: String)(fa: F[A]): F[A] = - cache.modify { plugins => - plugins.get(id) match { - - case Some(plugin) => - Applicative[F].pure { - plugins -> plugin.asInstanceOf[A] - } - - case None => - for { - plugin <- fa - } yield { - plugins.updated(id, plugin) -> plugin - } - - } - } - - new EventSourcedStoreOf[F, S, E] { - - override def apply( - eventSourced: EventSourced[_], - ): Resource[F, EventSourcedStore[F, S, E]] = { - val journalPath = eventSourced.pluginIds.journal getOrElse JournalFallbackConfigPath - val snapshotPath = eventSourced.pluginIds.snapshot getOrElse SnapshotStoreFallbackConfigPath - - val journalPlugin = Sync[F].delay { - PluginLoader.loadPlugin[AsyncWriteJournal](system, journalPath) - } - val snapshotPlugin = Sync[F].delay { - PluginLoader.loadPlugin[SnapshotStore](system, snapshotPath) - } - - for { - journalPlugin <- cached(journalPath)(journalPlugin).toResource - snapshotPlugin <- cached(snapshotPath)(snapshotPlugin).toResource - store <- fromAkkaPlugins[F, S, E](snapshotPlugin, journalPlugin) - } yield store - } - - } - } - - } + ): F[EventSourcedStoreOf[F, S, E]] = ??? /** * [[EventSourcedStore]] implementation based on Akka Persistence API. diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala new file mode 100644 index 00000000..fec3adbc --- /dev/null +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala @@ -0,0 +1,48 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.actor.ExtendedActorSystem +import cats.effect.implicits.effectResourceOps +import cats.syntax.all._ +import cats.effect.{Async, IO, Resource} +import cats.effect.unsafe.implicits.global +import com.evolutiongaming.akkaeffect.testkit.TestActorSystem +import com.evolutiongaming.catshelper.ToTry +import com.evolutiongaming.catshelper.CatsHelper._ +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +class EventSourcedStoreOfTest extends AnyFunSuite with Matchers { + + type F[A] = IO[A] + val F = Async[F] + + implicit val toTry = ToTry.ioToTry(global) + + type S = String + type E = String + + test("""EventSourcedStoreOf.fromAkka can use journal & snapshot plugins + |defined in Akka conf (in our case `test.conf`) + |without specifying plugin IDs in EventSourced.pluginIds""".stripMargin) { + + val id = EventSourcedId("id #1") + val es = EventSourced[Unit](id, value = {}) + + val resource = for { + as <- TestActorSystem[F]("testing", none) + as <- as.castM[Resource[F, *], ExtendedActorSystem] + of <- EventSourcedStoreOf.fromAkka[F, S, E](as).toResource + + store <- of(es) + + recovery0 <- store.recover(id) + _ = recovery0.snapshot shouldEqual none + + // TODO: finish me! + + } yield {} + + resource.use_.unsafeRunSync() + } + +} From 8dff02ebdaea57e30b58efb180ee509987c6bbee Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 30 Nov 2023 18:09:39 +0100 Subject: [PATCH 15/29] wip: implement PersistenceAdapter --- .gitignore | 6 +- .scalafmt.conf | 42 +++ .../akkaeffect/persistence/Event.scala | 7 +- .../persistence/EventSourcedStore.scala | 17 +- .../akkaeffect/persistence/Snapshot.scala | 10 +- .../akka/persistence/LocalActorRef.scala | 57 ++++ .../akka/persistence/PersistenceAdapter.scala | 265 ++++++++++++++++++ 7 files changed, 382 insertions(+), 22 deletions(-) create mode 100644 .scalafmt.conf create mode 100644 persistence/src/main/scala/akka/persistence/LocalActorRef.scala create mode 100644 persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala diff --git a/.gitignore b/.gitignore index e47379d6..16228640 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,8 @@ project/plugins/project/ # Mac .DS_Store -ignored \ No newline at end of file +metals.sbt +.metals +.bloop + +ignored diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 00000000..c169c6a7 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,42 @@ +version = 3.7.3 + +runner.dialect = scala213source3 + +maxColumn = 140 + +style = defaultWithAlign + +continuationIndent { + callSite = 2 + defnSite = 2 +} + +rewrite.rules = [ + RedundantBraces, RedundantParens, SortModifiers, prefercurlyfors +] + +newlines { + penalizeSingleSelectMultiArgList = false + alwaysBeforeElseAfterCurlyIf = false + beforeCurlyLambdaParams = multilineWithCaseOnly +} + +runner.optimizer.forceConfigStyleMinArgCount = 1 + +align { + + openParenDefnSite = false + openParenCallSite = false + + tokenCategory { + Equals = Assign + LeftArrow = Assign + } +} + +lineEndings = unix + +importSelectors = noBinPack + +danglingParentheses.preset = true +binPack.literalArgumentLists = true diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala index 3247a464..9e37ff84 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Event.scala @@ -9,9 +9,8 @@ trait Event[E] { object Event { - def const[E](e: E, nr: SeqNr): Event[E] = new Event[E] { - override def event: E = e - override def seqNr: SeqNr = nr - } + private case class Const[E](event: E, seqNr: SeqNr) extends Event[E] + + def const[E](event: E, seqNr: SeqNr): Event[E] = Const(event, seqNr) } diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala index 18096830..ad61303a 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala @@ -59,18 +59,13 @@ object EventSourcedStore { object Recovery { - def const[F[_], S, E](snapshot: Option[Snapshot[S]], - events: Stream[F, Event[E]]): Recovery[F, S, E] = { - - val (s, e) = (snapshot, events) - - new Recovery[F, S, E] { + private case class Const[F[_], S, E](snapshot: Option[Snapshot[S]], + events: Stream[F, Event[E]]) + extends Recovery[F, S, E] - override def snapshot: Option[Snapshot[S]] = s - override def events: Stream[F, Event[E]] = e - - } - } + def const[F[_], S, E](snapshot: Option[Snapshot[S]], + events: Stream[F, Event[E]]): Recovery[F, S, E] = + Const(snapshot, events) } diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala index 5959b69f..054a708b 100644 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Snapshot.scala @@ -13,11 +13,9 @@ object Snapshot { final case class Metadata(seqNr: SeqNr, timestamp: Instant) - def const[S](_snapshot: S, _metadata: Snapshot.Metadata): Snapshot[S] = - new Snapshot[S] { + private case class Const[S](snapshot: S, metadata: Snapshot.Metadata) + extends Snapshot[S] - override def snapshot: S = _snapshot - - override def metadata: Metadata = _metadata - } + def const[S](snapshot: S, metadata: Snapshot.Metadata): Snapshot[S] = + Const(snapshot, metadata) } diff --git a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala new file mode 100644 index 00000000..f51d220c --- /dev/null +++ b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala @@ -0,0 +1,57 @@ +package akka.persistence + +import akka.actor.{ActorRef, MinimalActorRef} +import cats.effect.{Async, Deferred} +import cats.syntax.all._ +import com.evolutiongaming.catshelper.CatsHelper.OpsCatsHelper +import com.evolutiongaming.catshelper.{SerialRef, ToFuture} + +trait LocalActorRef[F[_], R] { + + def ref: ActorRef + + def res: F[R] + + def get: F[Option[Either[Throwable, R]]] +} + +object LocalActorRef { + + type M = Any + + // TODO: implement also blocking impl based on ToTry instead of ToFuture + def apply[F[_]: Async: ToFuture, S, R](initial: S)(receive: (S, M) => F[Either[S, R]]): F[LocalActorRef[F, R]] = + for { + state <- SerialRef.of[F, S](initial) + defer <- Deferred[F, Either[Throwable, R]] + } yield new LocalActorRef[F, R] { + + override def ref: ActorRef = new MinimalActorRef { + + override def provider = throw new UnsupportedOperationException() + + override def path = throw new UnsupportedOperationException() + + override def !(m: M)(implicit sender: ActorRef): Unit = { + + val _ = state + .update { s => + receive(s, m).flatMap { + case Left(s) => s.pure[F] + case Right(r) => defer.complete(r.asRight).as(s) + } + } + .handleErrorWith { e => + defer.complete(e.asLeft).void + } + .toFuture + + } + } + + override def res: F[R] = defer.get.flatMap(_.liftTo[F]) + + override def get: F[Option[Either[Throwable, R]]] = defer.tryGet + } + +} diff --git a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala new file mode 100644 index 00000000..f582d4c7 --- /dev/null +++ b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala @@ -0,0 +1,265 @@ +package akka.persistence + +import akka.actor.ActorSystem +import cats.effect.{Async, Ref} +import cats.syntax.all._ +import com.evolutiongaming.akkaeffect.ActorEffect +import com.evolutiongaming.akkaeffect.persistence.{Append, DeleteEventsTo, Event, EventSourcedId, Events, SeqNr, Snapshot} +import com.evolutiongaming.catshelper.CatsHelper._ +import com.evolutiongaming.catshelper.{FromFuture, ToFuture} +import com.evolutiongaming.sstream.FoldWhile.FoldWhileOps +import com.evolutiongaming.sstream.Stream + +import java.time.Instant +import scala.concurrent.duration.FiniteDuration +import scala.reflect.ClassTag + +trait PersistenceAdapter[F[_]] { + + def snapshotter[S: ClassTag](snapshotPluginId: String, persistenceId: EventSourcedId): F[PersistenceAdapter.ExtendedSnapshotter[F, S]] + + def journaller[E: ClassTag](journalPluginId: String, persistenceId: EventSourcedId): F[PersistenceAdapter.ExtendedJournaller[F, E]] +} + +object PersistenceAdapter { + + trait ExtendedJournaller[F[_], E] extends com.evolutiongaming.akkaeffect.persistence.Journaller[F, E] { + + def replay(fromSequenceNr: Long, toSequenceNr: Long, max: Long): F[Stream[F, Event[E]]] + + } + + trait ExtendedSnapshotter[F[_], S] extends com.evolutiongaming.akkaeffect.persistence.Snapshotter[F, S] { + + def load(criteria: SnapshotSelectionCriteria, toSequenceNr: Long): F[F[Option[Snapshot[S]]]] + + } + + def of[F[_]: Async: ToFuture: FromFuture]( + system: ActorSystem, + askTimeout: FiniteDuration + ): F[PersistenceAdapter[F]] = { + + val F = Async[F] + + F.delay { + Persistence(system) + }.map { persistence => + new PersistenceAdapter[F] { + + override def snapshotter[S: ClassTag](snapshotPluginId: String, eventSourcedId: EventSourcedId): F[ExtendedSnapshotter[F, S]] = + F.delay { + persistence.snapshotStoreFor(snapshotPluginId) + }.map { actorRef => + val snapshotter = ActorEffect.fromActor(actorRef) + + new ExtendedSnapshotter[F, S] { + + val persistenceId = eventSourcedId.value + + override def load(criteria: SnapshotSelectionCriteria, toSequenceNr: Long): F[F[Option[Snapshot[S]]]] = { + + val request = SnapshotProtocol.LoadSnapshot(persistenceId, criteria, toSequenceNr) + snapshotter + .ask(request, askTimeout) + .map { response => + response.flatMap { + + case SnapshotProtocol.LoadSnapshotResult(snapshot, _) => + snapshot match { + + case Some(offer) => + offer.snapshot.castM[F, S].map { snapshot => + val metadata = Snapshot.Metadata( + offer.metadata.sequenceNr, + Instant.ofEpochMilli(offer.metadata.timestamp) + ) + Snapshot.const(snapshot, metadata).some + } + + case None => none[Snapshot[S]].pure[F] + } + + case SnapshotProtocol.LoadSnapshotFailed(err) => + err.raiseError[F, Option[Snapshot[S]]] + } + } + } + + override def save(seqNr: SeqNr, snapshot: S): F[F[Instant]] = { + val metadata = SnapshotMetadata(persistenceId, seqNr) + val request = SnapshotProtocol.SaveSnapshot(metadata, snapshot) + snapshotter + .ask(request, askTimeout) + .map { response => + response.flatMap { + case SaveSnapshotSuccess(metadata) => Instant.ofEpochMilli(metadata.timestamp).pure[F] + case SaveSnapshotFailure(_, err) => err.raiseError[F, Instant] + } + + } + } + + override def delete(seqNr: SeqNr): F[F[Unit]] = { + val metadata = SnapshotMetadata(persistenceId, seqNr) + val request = SnapshotProtocol.DeleteSnapshot(metadata) + snapshotter + .ask(request, askTimeout) + .map { response => + response.flatMap { + case DeleteSnapshotSuccess(_) => ().pure[F] + case DeleteSnapshotFailure(_, err) => err.raiseError[F, Unit] + } + } + } + + override def delete(criteria: SnapshotSelectionCriteria): F[F[Unit]] = { + val request = SnapshotProtocol.DeleteSnapshots(persistenceId, criteria) + snapshotter + .ask(request, askTimeout) + .map { response => + response.flatMap { + case DeleteSnapshotsSuccess(_) => ().pure[F] + case DeleteSnapshotsFailure(_, err) => err.raiseError[F, Unit] + } + } + } + + override def delete(criteria: com.evolutiongaming.akkaeffect.persistence.Snapshotter.Criteria): F[F[Unit]] = + delete(criteria.asAkka) + + } + } + + override def journaller[E: ClassTag](journalPluginId: String, eventSourcedId: EventSourcedId): F[ExtendedJournaller[F, E]] = { + + F.delay { + persistence.journalFor(journalPluginId) + }.map { actorRef => + val journaller = ActorEffect.fromActor(actorRef) + + new ExtendedJournaller[F, E] { + + val persistenceId = eventSourcedId.value + + override def replay(fromSequenceNr: SeqNr, toSequenceNr: SeqNr, max: SeqNr): F[Stream[F, Event[E]]] = { + + def actor(buffer: Ref[F, Vector[Event[E]]]) = + LocalActorRef[F, Unit, SeqNr] {} { + + case (_, JournalProtocol.ReplayedMessage(persisted)) => + if (persisted.deleted) ().asLeft[SeqNr].pure[F] + else + for { + e <- persisted.payload.castM[F, E] + event = Event.const(e, persisted.sequenceNr) + _ <- buffer.update(_ :+ event) + } yield ().asLeft[SeqNr] + + case (_, JournalProtocol.RecoverySuccess(seqNr)) => seqNr.asRight[Unit].pure[F] + + case (_, JournalProtocol.ReplayMessagesFailure(error)) => error.raiseError[F, Either[Unit, SeqNr]] + } + + for { + buffer <- Ref[F].of(Vector.empty[Event[E]]) + actor <- actor(buffer) + } yield new Stream[F, Event[E]] { + + override def foldWhileM[L, R](l: L)(f: (L, Event[E]) => F[Either[L, R]]): F[Either[L, R]] = + l.asLeft[R] + .tailRecM { + + case Left(l) => + for { + events <- buffer.getAndSet(Vector.empty[Event[E]]) + done <- actor.get + result <- events.foldWhileM(l)(f) + result <- result match { + + case l: Left[L, R] => + done match { + case Some(Right(_)) => l.asRight[Either[L, R]].pure[F] // no more events + case Some(Left(er)) => er.raiseError[F, Either[Either[L, R], Either[L, R]]] // failure + case None => l.asLeft[Either[L, R]].pure[F] // expecting more events + } + + // Right(...), cos user-function [[f]] desided to stop consuming stream thus wrapping in Right to break from tailRecM + case result => result.asRight[Either[L, R]].pure[F] + + } + } yield result + + case result => // cannot happened + result.asRight[Either[L, R]].pure[F] + } + + } + + } + + override def append: Append[F, E] = new Append[F, E] { + + override def apply(events: Events[E]): F[F[SeqNr]] = { + + case class State(writes: Int, maxSeqNr: SeqNr) + val state = State(events.values.length, SeqNr.Min) + val actor = LocalActorRef[F, State, SeqNr](state) { + + case (state, JournalProtocol.WriteMessagesSuccessful) => state.asLeft[SeqNr].pure[F] + + case (state, JournalProtocol.WriteMessageSuccess(persistent, _)) => + val seqNr = persistent.sequenceNr max state.maxSeqNr + val result = + if (state.writes == 1) seqNr.asRight[State] + else State(state.writes - 1, seqNr).asLeft[SeqNr] + result.pure[F] + + case (_, JournalProtocol.WriteMessageRejected(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] + + case (_, JournalProtocol.WriteMessagesFailed(error, _)) => error.raiseError[F, Either[State, SeqNr]] + + case (_, JournalProtocol.WriteMessageFailure(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] + } + + val messages = events.values.toList.map { events => + val persistent = events.toList.map { event => + PersistentRepr(event, persistenceId = persistenceId) + } + AtomicWrite(persistent) + } + + for { + actor <- actor + request = JournalProtocol.WriteMessages(messages, actor.ref, 0) + _ <- journaller.tell(request) + } yield actor.res // TODO: set timeout + } + + } + + override def deleteTo: DeleteEventsTo[F] = new DeleteEventsTo[F] { + + override def apply(seqNr: SeqNr): F[F[Unit]] = { + + val actor = LocalActorRef[F, Unit, Unit] {} { + case (_, DeleteMessagesSuccess(_)) => ().asRight[Unit].pure[F] + case (_, DeleteMessagesFailure(e, _)) => e.raiseError[F, Either[Unit, Unit]] + } + + for { + actor <- actor + request = JournalProtocol.DeleteMessagesTo(persistenceId, seqNr, actor.ref) + _ <- journaller.tell(request) + } yield actor.res // TODO: set timeout + } + + } + } + } + } + } + } + + } +} From e7c54adffadc19ec4e8ddc526dc0e5939b10d1eb Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Fri, 1 Dec 2023 17:16:41 +0100 Subject: [PATCH 16/29] make LocalActorRef.! sync & add tests --- .../akka/persistence/LocalActorRef.scala | 9 ++- .../akka/persistence/PersistenceAdapter.scala | 4 +- .../akka/persistence/LocalActorRefTest.scala | 63 +++++++++++++++++++ 3 files changed, 69 insertions(+), 7 deletions(-) create mode 100644 persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala diff --git a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala index f51d220c..cd9bde0e 100644 --- a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala +++ b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala @@ -1,10 +1,10 @@ package akka.persistence import akka.actor.{ActorRef, MinimalActorRef} -import cats.effect.{Async, Deferred} +import cats.effect.{Concurrent, Deferred} import cats.syntax.all._ import com.evolutiongaming.catshelper.CatsHelper.OpsCatsHelper -import com.evolutiongaming.catshelper.{SerialRef, ToFuture} +import com.evolutiongaming.catshelper.{SerialRef, ToTry} trait LocalActorRef[F[_], R] { @@ -19,8 +19,7 @@ object LocalActorRef { type M = Any - // TODO: implement also blocking impl based on ToTry instead of ToFuture - def apply[F[_]: Async: ToFuture, S, R](initial: S)(receive: (S, M) => F[Either[S, R]]): F[LocalActorRef[F, R]] = + def apply[F[_]: Concurrent: ToTry, S, R](initial: S)(receive: (S, M) => F[Either[S, R]]): F[LocalActorRef[F, R]] = for { state <- SerialRef.of[F, S](initial) defer <- Deferred[F, Either[Throwable, R]] @@ -44,7 +43,7 @@ object LocalActorRef { .handleErrorWith { e => defer.complete(e.asLeft).void } - .toFuture + .toTry } } diff --git a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala index f582d4c7..ec97ab21 100644 --- a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala +++ b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala @@ -6,7 +6,7 @@ import cats.syntax.all._ import com.evolutiongaming.akkaeffect.ActorEffect import com.evolutiongaming.akkaeffect.persistence.{Append, DeleteEventsTo, Event, EventSourcedId, Events, SeqNr, Snapshot} import com.evolutiongaming.catshelper.CatsHelper._ -import com.evolutiongaming.catshelper.{FromFuture, ToFuture} +import com.evolutiongaming.catshelper.{FromFuture, ToTry} import com.evolutiongaming.sstream.FoldWhile.FoldWhileOps import com.evolutiongaming.sstream.Stream @@ -35,7 +35,7 @@ object PersistenceAdapter { } - def of[F[_]: Async: ToFuture: FromFuture]( + def of[F[_]: Async: ToTry: FromFuture]( system: ActorSystem, askTimeout: FiniteDuration ): F[PersistenceAdapter[F]] = { diff --git a/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala b/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala new file mode 100644 index 00000000..7da58052 --- /dev/null +++ b/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala @@ -0,0 +1,63 @@ +package akka.persistence + +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import cats.syntax.all._ +import org.scalatest.matchers.should.Matchers +import org.scalatest.funsuite.AnyFunSuite + +class LocalActorRefTest extends AnyFunSuite with Matchers { + + val poison = 100500 + + def of = LocalActorRef[IO, Int, Int](0) { + case (s, `poison`) => IO(s.asRight) + case (s, m: Int) => IO((s + m).asLeft) + } + + test("LocalActorRef can handle messages and produce result") { + val io = for { + r <- of + n <- r.get + _ = n shouldEqual none + _ = r.ref ! 3 + _ = r.ref ! 4 + _ = r.ref ! poison + r <- r.res + _ = r shouldEqual 7 + } yield {} + + io.unsafeRunSync() + } + + test("LocalActorRef.res semantically blocks until result produced") { + val io = for { + d <- IO.deferred[Unit] + r <- of + f = r.res >> d.complete {} + f <- f.start + d0 <- d.tryGet + _ = d0 shouldEqual none + _ = r.ref ! poison + _ <- f.join + d1 <- d.tryGet + _ = d1 shouldEqual {}.some + } yield {} + + io.unsafeRunSync() + } + + test("LocalActorRef should handle concurrent ! operations") { + val io = for { + r <- of + l = List.range(0, 100) + _ <- l.parTraverse(i => IO(r.ref ! i)) + _ = r.ref ! poison + r <- r.res + _ = r shouldEqual l.sum + } yield {} + + io.unsafeRunSync() + } + +} From 00069e343181af9069e841f5282f4128d3d6ee56 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Fri, 1 Dec 2023 18:02:31 +0100 Subject: [PATCH 17/29] wip: start working on PersistenceAdapterTest --- .../persistence/PersistenceAdapterTest.scala | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala diff --git a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala new file mode 100644 index 00000000..b28977e8 --- /dev/null +++ b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala @@ -0,0 +1,62 @@ +package akka.persistence + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import cats.syntax.all._ +import cats.effect.IO +import cats.effect.unsafe.implicits.global + +import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr} +import com.evolutiongaming.akkaeffect.testkit.TestActorSystem +import scala.concurrent.duration._ + +import scala.util.Random + +class PersistenceAdapterTest extends AnyFunSuite with Matchers { + + val emptyPluginId = "" + + test("snapshot: load, save and load again") { + + val persistenceId = EventSourcedId("#1") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](emptyPluginId, persistenceId) + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot shouldEqual none + _ <- snapshotter.save(SeqNr.Min, payload).flatten + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot.get.snapshot should equal(payload) + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: save, load, delete and load again") { + + val persistenceId = EventSourcedId("#2") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](emptyPluginId, persistenceId) + _ <- snapshotter.save(SeqNr.Min, payload).flatten + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot.get.snapshot should equal(payload) + _ <- snapshotter.delete(SeqNr.Min).flatten + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot shouldEqual none + } yield {} + } + + io.unsafeRunSync() + } +} From 8d0fdf35b33d50658a0a96dd579457c0c0a3ad74 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Tue, 5 Dec 2023 18:57:54 +0100 Subject: [PATCH 18/29] wip: add tests for journaller/snaps=shotter failures --- .../akka/persistence/LocalActorRef.scala | 6 +- .../akka/persistence/PersistenceAdapter.scala | 7 +- persistence/src/test/resources/test.conf | 15 ++ .../persistence/PersistenceAdapterTest.scala | 205 +++++++++++++++++- .../persistence/EventSourcedStoreOfTest.scala | 2 +- 5 files changed, 229 insertions(+), 6 deletions(-) diff --git a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala index cd9bde0e..929dab0e 100644 --- a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala +++ b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala @@ -6,7 +6,7 @@ import cats.syntax.all._ import com.evolutiongaming.catshelper.CatsHelper.OpsCatsHelper import com.evolutiongaming.catshelper.{SerialRef, ToTry} -trait LocalActorRef[F[_], R] { +private[persistence] trait LocalActorRef[F[_], R] { def ref: ActorRef @@ -15,7 +15,7 @@ trait LocalActorRef[F[_], R] { def get: F[Option[Either[Throwable, R]]] } -object LocalActorRef { +private[persistence] object LocalActorRef { type M = Any @@ -52,5 +52,5 @@ object LocalActorRef { override def get: F[Option[Either[Throwable, R]]] = defer.tryGet } - + } diff --git a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala index ec97ab21..b5d228a9 100644 --- a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala +++ b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala @@ -35,6 +35,9 @@ object PersistenceAdapter { } + // TODO: + // 1. set timeout for journaller ops? IMO call-side can do it as well + // 2. set buffer limit? Akka Persistence does not have such limits, should we? def of[F[_]: Async: ToTry: FromFuture]( system: ActorSystem, askTimeout: FiniteDuration @@ -164,6 +167,8 @@ object PersistenceAdapter { for { buffer <- Ref[F].of(Vector.empty[Event[E]]) actor <- actor(buffer) + request = JournalProtocol.ReplayMessages(fromSequenceNr, toSequenceNr, max, persistenceId, actor.ref) + _ <- journaller.tell(request) } yield new Stream[F, Event[E]] { override def foldWhileM[L, R](l: L)(f: (L, Event[E]) => F[Either[L, R]]): F[Either[L, R]] = @@ -184,7 +189,7 @@ object PersistenceAdapter { case None => l.asLeft[Either[L, R]].pure[F] // expecting more events } - // Right(...), cos user-function [[f]] desided to stop consuming stream thus wrapping in Right to break from tailRecM + // Right(...), cos user-defined function [[f]] desided to stop consuming stream thus wrapping in Right to break tailRecM loop case result => result.asRight[Either[L, R]].pure[F] } diff --git a/persistence/src/test/resources/test.conf b/persistence/src/test/resources/test.conf index 671fe28a..5114bbfc 100644 --- a/persistence/src/test/resources/test.conf +++ b/persistence/src/test/resources/test.conf @@ -7,4 +7,19 @@ akka { journal.plugin = "inmemory-journal" snapshot-store.plugin = "inmemory-snapshot-store" } +} + +failing-journal { + class = "akka.persistence.FailingJournal" + plugin-dispatcher = "akka.actor.default-dispatcher" +} + +infinite-journal { + class = "akka.persistence.InfiniteJournal" + plugin-dispatcher = "akka.actor.default-dispatcher" +} + +failing-snapshot { + class = "akka.persistence.FailingSnapshotter" + plugin-dispatcher = "akka.actor.default-dispatcher" } \ No newline at end of file diff --git a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala index b28977e8..cf81bfe3 100644 --- a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala +++ b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala @@ -7,11 +7,16 @@ import cats.syntax.all._ import cats.effect.IO import cats.effect.unsafe.implicits.global -import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr} +import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr, Event, Snapshotter} import com.evolutiongaming.akkaeffect.testkit.TestActorSystem import scala.concurrent.duration._ import scala.util.Random +import akka.persistence.journal.AsyncWriteJournal +import scala.concurrent.Future +import scala.util.Try +import com.evolutiongaming.akkaeffect.persistence.Events +import akka.persistence.snapshot.SnapshotStore class PersistenceAdapterTest extends AnyFunSuite with Matchers { @@ -59,4 +64,202 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { io.unsafeRunSync() } + + test("snapshot: fail load snapshot") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#3") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue) + error <- snapshot.attempt + _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail save snapshot") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#4") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + saving <- snapshotter.save(SeqNr.Min, payload) + error <- saving.attempt + _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail delete snapshot") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#5") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + deleting <- snapshotter.delete(SeqNr.Min) + error <- deleting.attempt + _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail delete snapshot by criteria") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#5") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + deleting <- snapshotter.delete(Snapshotter.Criteria()) + error <- deleting.attempt + _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("journal: fail loading events") { + + val pluginId = "failing-journal" + val persistenceId = EventSourcedId("#3") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + journal <- adapter.journaller[String](pluginId, persistenceId) + events <- journal.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + error <- events.toList.attempt + _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("journal: fail persisting events") { + + val pluginId = "failing-journal" + val persistenceId = EventSourcedId("#4") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + journal <- adapter.journaller[String](pluginId, persistenceId) + seqNr <- journal.append(Events.of[String]("first", "second")) + error <- seqNr.attempt + _ = error shouldEqual Expected.exception.asLeft[SeqNr] + } yield {} + } + + io.unsafeRunSync() + } + + test("journal: fail deleting events") { + + val pluginId = "failing-journal" + val persistenceId = EventSourcedId("#5") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + journal <- adapter.journaller[String](pluginId, persistenceId) + deleting <- journal.deleteTo(SeqNr.Max) + error <- deleting.attempt + _ = error shouldEqual Expected.exception.asLeft[Unit] + } yield {} + } + + io.unsafeRunSync() + } + + // test("journal: timeout on loading events") { + + // val pluginId = "infinite-journal" + // val persistenceId = EventSourcedId("#6") + + // val io = TestActorSystem[IO]("testing", none) + // .use { system => + // for { + // adapter <- PersistenceAdapter.of[IO](system, 1.second) + // journal <- adapter.journaller[String](pluginId, persistenceId) + // events <- journal.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + // error <- events.toList.attempt + // _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] + // } yield {} + // } + + // io.unsafeRunSync() + // } +} + +object Expected { + val exception = new RuntimeException("test exception") +} + +class FailingJournal extends AsyncWriteJournal { + + override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)( + recoveryCallback: PersistentRepr => Unit + ): Future[Unit] = Future.failed(Expected.exception) + + override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = Future.failed(Expected.exception) + + override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = Future.failed(Expected.exception) + + override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.failed(Expected.exception) + +} + +class InfiniteJournal extends AsyncWriteJournal { + + override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)( + recoveryCallback: PersistentRepr => Unit + ): Future[Unit] = Future.never + + override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = Future.never + + override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = Future.never + + override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.never + +} + +class FailingSnapshotter extends SnapshotStore { + + override def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = + Future.failed(Expected.exception) + + override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit] = Future.failed(Expected.exception) + + override def deleteAsync(metadata: SnapshotMetadata): Future[Unit] = Future.failed(Expected.exception) + + override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = Future.failed(Expected.exception) + } diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala index fec3adbc..03e67c22 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala @@ -21,7 +21,7 @@ class EventSourcedStoreOfTest extends AnyFunSuite with Matchers { type S = String type E = String - test("""EventSourcedStoreOf.fromAkka can use journal & snapshot plugins + ignore("""EventSourcedStoreOf.fromAkka can use journal & snapshot plugins |defined in Akka conf (in our case `test.conf`) |without specifying plugin IDs in EventSourced.pluginIds""".stripMargin) { From 14212bf111959ae4e7ec501329d7b4964129fb24 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Wed, 6 Dec 2023 12:01:37 +0100 Subject: [PATCH 19/29] wip: add tests for snapshotter timeouts --- persistence/src/test/resources/test.conf | 5 + .../persistence/PersistenceAdapterTest.scala | 109 +++++++++++++++++- 2 files changed, 110 insertions(+), 4 deletions(-) diff --git a/persistence/src/test/resources/test.conf b/persistence/src/test/resources/test.conf index 5114bbfc..c145fa7b 100644 --- a/persistence/src/test/resources/test.conf +++ b/persistence/src/test/resources/test.conf @@ -22,4 +22,9 @@ infinite-journal { failing-snapshot { class = "akka.persistence.FailingSnapshotter" plugin-dispatcher = "akka.actor.default-dispatcher" +} + +infinite-snapshot { + class = "akka.persistence.InfiniteSnapshotter" + plugin-dispatcher = "akka.actor.default-dispatcher" } \ No newline at end of file diff --git a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala index cf81bfe3..f9df2963 100644 --- a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala +++ b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala @@ -126,7 +126,7 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { test("snapshot: fail delete snapshot by criteria") { val pluginId = "failing-snapshot" - val persistenceId = EventSourcedId("#5") + val persistenceId = EventSourcedId("#6") val io = TestActorSystem[IO]("testing", none) .use { system => @@ -142,10 +142,99 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { io.unsafeRunSync() } + test("snapshot: timeout loading snapshot") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#7") + + val io = TestActorSystem[IO]("testing", none) + .use(system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue) + error <- snapshot.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + ) + + io.unsafeRunSync() + } + + test("snapshot: timeout saving snapshot") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#8") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + saving <- snapshotter.save(SeqNr.Min, payload) + error <- saving.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + } + + io.unsafeRunSync() + } + + test("snapshot: timeout deleting snapshot") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#9") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + deleting <- snapshotter.delete(SeqNr.Min) + error <- deleting.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + } + + io.unsafeRunSync() + } + + test("snapshot: timeout deleting snapshot by criteria") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#10") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) + deleting <- snapshotter.delete(Snapshotter.Criteria()) + error <- deleting.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + } + + io.unsafeRunSync() + } + test("journal: fail loading events") { val pluginId = "failing-journal" - val persistenceId = EventSourcedId("#3") + val persistenceId = EventSourcedId("#11") val io = TestActorSystem[IO]("testing", none) .use { system => @@ -164,7 +253,7 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { test("journal: fail persisting events") { val pluginId = "failing-journal" - val persistenceId = EventSourcedId("#4") + val persistenceId = EventSourcedId("#12") val io = TestActorSystem[IO]("testing", none) .use { system => @@ -183,7 +272,7 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { test("journal: fail deleting events") { val pluginId = "failing-journal" - val persistenceId = EventSourcedId("#5") + val persistenceId = EventSourcedId("#13") val io = TestActorSystem[IO]("testing", none) .use { system => @@ -263,3 +352,15 @@ class FailingSnapshotter extends SnapshotStore { override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = Future.failed(Expected.exception) } + +class InfiniteSnapshotter extends SnapshotStore { + + override def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = Future.never + + override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit] = Future.never + + override def deleteAsync(metadata: SnapshotMetadata): Future[Unit] = Future.never + + override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = Future.never + +} From b1edd41020bd21b7c8cc10457be4e5edafc3c408 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Wed, 6 Dec 2023 15:48:57 +0100 Subject: [PATCH 20/29] wip: add timeout into LocalActorRef --- .../akka/persistence/LocalActorRef.scala | 100 +++++++++++++++--- .../akka/persistence/PersistenceAdapter.scala | 19 ++-- .../akka/persistence/LocalActorRefTest.scala | 52 +++++++-- .../persistence/PersistenceAdapterTest.scala | 6 +- 4 files changed, 142 insertions(+), 35 deletions(-) diff --git a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala index 929dab0e..a8c379a3 100644 --- a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala +++ b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala @@ -1,17 +1,39 @@ package akka.persistence import akka.actor.{ActorRef, MinimalActorRef} -import cats.effect.{Concurrent, Deferred} +import cats.effect.Temporal import cats.syntax.all._ import com.evolutiongaming.catshelper.CatsHelper.OpsCatsHelper import com.evolutiongaming.catshelper.{SerialRef, ToTry} - +import scala.concurrent.duration.FiniteDuration +import java.time.Instant +import java.util.concurrent.TimeoutException +import java.time.temporal.ChronoUnit + +/** Representation of actor capable of constructing result from multiple messages passed into the actor. Inspired by [[PromiseActorRef]] but + * result [[R]] is an aggregate from incomming messages rather that first message. Can be used only locally, does _not_ tolerate. + * [[ActorRef.provider]] and [[ActorRef.path]] functions. + * @tparam F + * The effect type. + * @tparam R + * The result type of the aggregate. + */ private[persistence] trait LocalActorRef[F[_], R] { def ref: ActorRef + /** Semantically blocking while aggregating result + */ def res: F[R] + /** Immidiately get currect state: + * + * \- [[None]] if aggregating not finished yet + * + * \- [[Some(Left(Throwable))]] if aggregation failed or timeout happened + * + * \- [[Some(Right(r))]] if aggregation completed successfully + */ def get: F[Option[Either[Throwable, R]]] } @@ -19,38 +41,88 @@ private[persistence] object LocalActorRef { type M = Any - def apply[F[_]: Concurrent: ToTry, S, R](initial: S)(receive: (S, M) => F[Either[S, R]]): F[LocalActorRef[F, R]] = + /** Create new [[LocalActorRef]] + * + * @param initial + * The initial state of type [[S]]. + * @param timeout + * [[TimeoutException]] will be thrown if no incomming messages received within the timeout. + * @param receive + * The aggregate function defining how to apply incomming message on state or produce final result: [[Left]] for continue aggregating + * while [[Right]] for the result. + * @tparam F + * The effect type. + * @tparam S + * The aggregating state type. + * @tparam R + * The final result type. + * @return + */ + def apply[F[_]: Temporal: ToTry, S, R](initial: S, timeout: FiniteDuration)( + receive: (S, M) => F[Either[S, R]] + ): F[LocalActorRef[F, R]] = { + + val F = Temporal[F] + + case class State(state: S, updated: Instant) + + def timeoutException = new TimeoutException(s"no messages received during period of $timeout") + for { - state <- SerialRef.of[F, S](initial) - defer <- Deferred[F, Either[Throwable, R]] + now <- F.realTimeInstant + state <- SerialRef.of[F, State](State(initial, now)) + defer <- F.deferred[Either[Throwable, R]] + fiber <- F.start { + val f = for { + _ <- F.sleep(timeout) + s <- state.get + n <- F.realTimeInstant + c = s.updated.plus(timeout.toNanos, ChronoUnit.NANOS).isBefore(n) + _ <- if (c) defer.complete(timeoutException.asLeft) else F.unit + } yield c + + ().tailRecM { _ => + f.ifF(().asRight, ().asLeft) + } + } } yield new LocalActorRef[F, R] { + private def done(e: Either[Throwable, R]) = + for { + _ <- defer.complete(e) + _ <- fiber.cancel + } yield {} + override def ref: ActorRef = new MinimalActorRef { override def provider = throw new UnsupportedOperationException() override def path = throw new UnsupportedOperationException() - override def !(m: M)(implicit sender: ActorRef): Unit = { - - val _ = state + override def !(m: M)(implicit sender: ActorRef): Unit = + state .update { s => - receive(s, m).flatMap { - case Left(s) => s.pure[F] - case Right(r) => defer.complete(r.asRight).as(s) - } + for { + t <- Temporal[F].realTimeInstant + r <- receive(s.state, m) + s <- r match { + case Left(s) => State(s, t).pure[F] + case Right(r) => done(r.asRight).as(s) + } + } yield s } .handleErrorWith { e => - defer.complete(e.asLeft).void + done(e.asLeft).void } .toTry + .get - } } override def res: F[R] = defer.get.flatMap(_.liftTo[F]) override def get: F[Option[Either[Throwable, R]]] = defer.tryGet } + } } diff --git a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala index b5d228a9..a04b7319 100644 --- a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala +++ b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala @@ -35,12 +35,9 @@ object PersistenceAdapter { } - // TODO: - // 1. set timeout for journaller ops? IMO call-side can do it as well - // 2. set buffer limit? Akka Persistence does not have such limits, should we? def of[F[_]: Async: ToTry: FromFuture]( system: ActorSystem, - askTimeout: FiniteDuration + timeout: FiniteDuration ): F[PersistenceAdapter[F]] = { val F = Async[F] @@ -64,7 +61,7 @@ object PersistenceAdapter { val request = SnapshotProtocol.LoadSnapshot(persistenceId, criteria, toSequenceNr) snapshotter - .ask(request, askTimeout) + .ask(request, timeout) .map { response => response.flatMap { @@ -93,7 +90,7 @@ object PersistenceAdapter { val metadata = SnapshotMetadata(persistenceId, seqNr) val request = SnapshotProtocol.SaveSnapshot(metadata, snapshot) snapshotter - .ask(request, askTimeout) + .ask(request, timeout) .map { response => response.flatMap { case SaveSnapshotSuccess(metadata) => Instant.ofEpochMilli(metadata.timestamp).pure[F] @@ -107,7 +104,7 @@ object PersistenceAdapter { val metadata = SnapshotMetadata(persistenceId, seqNr) val request = SnapshotProtocol.DeleteSnapshot(metadata) snapshotter - .ask(request, askTimeout) + .ask(request, timeout) .map { response => response.flatMap { case DeleteSnapshotSuccess(_) => ().pure[F] @@ -119,7 +116,7 @@ object PersistenceAdapter { override def delete(criteria: SnapshotSelectionCriteria): F[F[Unit]] = { val request = SnapshotProtocol.DeleteSnapshots(persistenceId, criteria) snapshotter - .ask(request, askTimeout) + .ask(request, timeout) .map { response => response.flatMap { case DeleteSnapshotsSuccess(_) => ().pure[F] @@ -148,7 +145,7 @@ object PersistenceAdapter { override def replay(fromSequenceNr: SeqNr, toSequenceNr: SeqNr, max: SeqNr): F[Stream[F, Event[E]]] = { def actor(buffer: Ref[F, Vector[Event[E]]]) = - LocalActorRef[F, Unit, SeqNr] {} { + LocalActorRef[F, Unit, SeqNr]({}, timeout) { case (_, JournalProtocol.ReplayedMessage(persisted)) => if (persisted.deleted) ().asLeft[SeqNr].pure[F] @@ -209,7 +206,7 @@ object PersistenceAdapter { case class State(writes: Int, maxSeqNr: SeqNr) val state = State(events.values.length, SeqNr.Min) - val actor = LocalActorRef[F, State, SeqNr](state) { + val actor = LocalActorRef[F, State, SeqNr](state, timeout) { case (state, JournalProtocol.WriteMessagesSuccessful) => state.asLeft[SeqNr].pure[F] @@ -247,7 +244,7 @@ object PersistenceAdapter { override def apply(seqNr: SeqNr): F[F[Unit]] = { - val actor = LocalActorRef[F, Unit, Unit] {} { + val actor = LocalActorRef[F, Unit, Unit]({}, timeout) { case (_, DeleteMessagesSuccess(_)) => ().asRight[Unit].pure[F] case (_, DeleteMessagesFailure(e, _)) => e.raiseError[F, Either[Unit, Unit]] } diff --git a/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala b/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala index 7da58052..46f4452d 100644 --- a/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala +++ b/persistence/src/test/scala/akka/persistence/LocalActorRefTest.scala @@ -5,12 +5,15 @@ import cats.effect.unsafe.implicits.global import cats.syntax.all._ import org.scalatest.matchers.should.Matchers import org.scalatest.funsuite.AnyFunSuite +import scala.concurrent.duration._ +import java.util.concurrent.TimeoutException class LocalActorRefTest extends AnyFunSuite with Matchers { - val poison = 100500 + val poison = 100500 + val timeout = 1.second - def of = LocalActorRef[IO, Int, Int](0) { + def of = LocalActorRef[IO, Int, Int](0, timeout) { case (s, `poison`) => IO(s.asRight) case (s, m: Int) => IO((s + m).asLeft) } @@ -20,9 +23,9 @@ class LocalActorRefTest extends AnyFunSuite with Matchers { r <- of n <- r.get _ = n shouldEqual none - _ = r.ref ! 3 - _ = r.ref ! 4 - _ = r.ref ! poison + _ <- IO(r.ref ! 3) + _ <- IO(r.ref ! 4) + _ <- IO(r.ref ! poison) r <- r.res _ = r shouldEqual 7 } yield {} @@ -38,7 +41,7 @@ class LocalActorRefTest extends AnyFunSuite with Matchers { f <- f.start d0 <- d.tryGet _ = d0 shouldEqual none - _ = r.ref ! poison + _ <- IO(r.ref ! poison) _ <- f.join d1 <- d.tryGet _ = d1 shouldEqual {}.some @@ -52,7 +55,7 @@ class LocalActorRefTest extends AnyFunSuite with Matchers { r <- of l = List.range(0, 100) _ <- l.parTraverse(i => IO(r.ref ! i)) - _ = r.ref ! poison + _ <- IO(r.ref ! poison) r <- r.res _ = r shouldEqual l.sum } yield {} @@ -60,4 +63,39 @@ class LocalActorRefTest extends AnyFunSuite with Matchers { io.unsafeRunSync() } + test(s"LocalActorRef should timeout aftet $timeout") { + val io = for { + r <- of + _ <- IO.sleep(timeout * 2) + e <- r.get + _ = e match { + case Some(Left(_: TimeoutException)) => succeed + case other => fail(s"unexpected result $other") + } + e <- r.res.attempt + _ = e match { + case Left(_: TimeoutException) => succeed + case other => fail(s"unexpected result $other") + } + } yield {} + + io.unsafeRunSync() + } + + test("LocalActorRef should not timeout while receiving messages within timeout span") { + val io = for { + r <- of + _ <- IO.sleep(timeout / 2) + _ <- IO(r.ref ! 2) + _ <- IO.sleep(timeout / 2) + _ <- IO(r.ref ! 3) + _ <- IO.sleep(timeout / 2) + _ <- IO(r.ref ! poison) + r <- r.res + _ = r shouldEqual 5 + } yield {} + + io.unsafeRunSync() + } + } diff --git a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala index f9df2963..4a4cce18 100644 --- a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala +++ b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala @@ -7,16 +7,16 @@ import cats.syntax.all._ import cats.effect.IO import cats.effect.unsafe.implicits.global -import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr, Event, Snapshotter} +import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr, Event, Events, Snapshotter} import com.evolutiongaming.akkaeffect.testkit.TestActorSystem -import scala.concurrent.duration._ import scala.util.Random import akka.persistence.journal.AsyncWriteJournal import scala.concurrent.Future +import scala.concurrent.duration._ import scala.util.Try -import com.evolutiongaming.akkaeffect.persistence.Events import akka.persistence.snapshot.SnapshotStore +import akka.pattern.AskTimeoutException class PersistenceAdapterTest extends AnyFunSuite with Matchers { From c04d036e0b80bc4097329189bcf9ea65ebc08439 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Wed, 6 Dec 2023 15:58:34 +0100 Subject: [PATCH 21/29] wip: use partial func in LocalActorRef --- .../akka/persistence/LocalActorRef.scala | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala index a8c379a3..fb1ee278 100644 --- a/persistence/src/main/scala/akka/persistence/LocalActorRef.scala +++ b/persistence/src/main/scala/akka/persistence/LocalActorRef.scala @@ -59,7 +59,7 @@ private[persistence] object LocalActorRef { * @return */ def apply[F[_]: Temporal: ToTry, S, R](initial: S, timeout: FiniteDuration)( - receive: (S, M) => F[Either[S, R]] + receive: PartialFunction[(S, M), F[Either[S, R]]] ): F[LocalActorRef[F, R]] = { val F = Temporal[F] @@ -102,14 +102,20 @@ private[persistence] object LocalActorRef { override def !(m: M)(implicit sender: ActorRef): Unit = state .update { s => - for { - t <- Temporal[F].realTimeInstant - r <- receive(s.state, m) - s <- r match { - case Left(s) => State(s, t).pure[F] - case Right(r) => done(r.asRight).as(s) - } - } yield s + if (receive.isDefinedAt(s.state -> m)) { + + for { + t <- Temporal[F].realTimeInstant + r <- receive(s.state -> m) + s <- r match { + case Left(s) => State(s, t).pure[F] + case Right(r) => done(r.asRight).as(s) + } + } yield s + + } else { + s.pure[F] + } } .handleErrorWith { e => done(e.asLeft).void From 339d37d602c332953e7ed78b33c2bc073ca9e98e Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 7 Dec 2023 13:15:25 +0100 Subject: [PATCH 22/29] wip: add seqNr tracking --- .../akka/persistence/PersistenceAdapter.scala | 68 +++++++---- .../persistence/PersistenceAdapterTest.scala | 113 +++++++++++++++--- 2 files changed, 139 insertions(+), 42 deletions(-) diff --git a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala index a04b7319..875bec28 100644 --- a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala +++ b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala @@ -18,23 +18,28 @@ trait PersistenceAdapter[F[_]] { def snapshotter[S: ClassTag](snapshotPluginId: String, persistenceId: EventSourcedId): F[PersistenceAdapter.ExtendedSnapshotter[F, S]] - def journaller[E: ClassTag](journalPluginId: String, persistenceId: EventSourcedId): F[PersistenceAdapter.ExtendedJournaller[F, E]] + def journaller[E: ClassTag]( + journalPluginId: String, + persistenceId: EventSourcedId, + currentSeqNr: SeqNr + ): F[PersistenceAdapter.ExtendedJournaller[F, E]] } object PersistenceAdapter { trait ExtendedJournaller[F[_], E] extends com.evolutiongaming.akkaeffect.persistence.Journaller[F, E] { - def replay(fromSequenceNr: Long, toSequenceNr: Long, max: Long): F[Stream[F, Event[E]]] + def replay(toSequenceNr: SeqNr, max: Long): F[Stream[F, Event[E]]] } trait ExtendedSnapshotter[F[_], S] extends com.evolutiongaming.akkaeffect.persistence.Snapshotter[F, S] { - def load(criteria: SnapshotSelectionCriteria, toSequenceNr: Long): F[F[Option[Snapshot[S]]]] + def load(criteria: SnapshotSelectionCriteria, toSequenceNr: SeqNr): F[F[Option[Snapshot[S]]]] } + // TODO: set buffer limit def of[F[_]: Async: ToTry: FromFuture]( system: ActorSystem, timeout: FiniteDuration @@ -57,7 +62,7 @@ object PersistenceAdapter { val persistenceId = eventSourcedId.value - override def load(criteria: SnapshotSelectionCriteria, toSequenceNr: Long): F[F[Option[Snapshot[S]]]] = { + override def load(criteria: SnapshotSelectionCriteria, toSequenceNr: SeqNr): F[F[Option[Snapshot[S]]]] = { val request = SnapshotProtocol.LoadSnapshot(persistenceId, criteria, toSequenceNr) snapshotter @@ -131,23 +136,31 @@ object PersistenceAdapter { } } - override def journaller[E: ClassTag](journalPluginId: String, eventSourcedId: EventSourcedId): F[ExtendedJournaller[F, E]] = { + override def journaller[E: ClassTag]( + journalPluginId: String, + eventSourcedId: EventSourcedId, + currentSeqNr: SeqNr + ): F[ExtendedJournaller[F, E]] = { - F.delay { - persistence.journalFor(journalPluginId) - }.map { actorRef => - val journaller = ActorEffect.fromActor(actorRef) + for { + pluginActorRef <- F.delay { + persistence.journalFor(journalPluginId) + } + appendedSeqNr <- F.ref(currentSeqNr) + } yield { + val journaller = ActorEffect.fromActor(pluginActorRef) new ExtendedJournaller[F, E] { val persistenceId = eventSourcedId.value - override def replay(fromSequenceNr: SeqNr, toSequenceNr: SeqNr, max: SeqNr): F[Stream[F, Event[E]]] = { + override def replay(toSequenceNr: SeqNr, max: Long): F[Stream[F, Event[E]]] = { def actor(buffer: Ref[F, Vector[Event[E]]]) = LocalActorRef[F, Unit, SeqNr]({}, timeout) { case (_, JournalProtocol.ReplayedMessage(persisted)) => + println(s"journal, replaying: $persisted") if (persisted.deleted) ().asLeft[SeqNr].pure[F] else for { @@ -156,7 +169,9 @@ object PersistenceAdapter { _ <- buffer.update(_ :+ event) } yield ().asLeft[SeqNr] - case (_, JournalProtocol.RecoverySuccess(seqNr)) => seqNr.asRight[Unit].pure[F] + case (_, JournalProtocol.RecoverySuccess(seqNr)) => + println(s"journal, recovery success $seqNr") + appendedSeqNr.set(seqNr).as(seqNr.asRight[Unit]) case (_, JournalProtocol.ReplayMessagesFailure(error)) => error.raiseError[F, Either[Unit, SeqNr]] } @@ -164,7 +179,7 @@ object PersistenceAdapter { for { buffer <- Ref[F].of(Vector.empty[Event[E]]) actor <- actor(buffer) - request = JournalProtocol.ReplayMessages(fromSequenceNr, toSequenceNr, max, persistenceId, actor.ref) + request = JournalProtocol.ReplayMessages(currentSeqNr + 1, toSequenceNr, max, persistenceId, actor.ref) _ <- journaller.tell(request) } yield new Stream[F, Event[E]] { @@ -204,8 +219,8 @@ object PersistenceAdapter { override def apply(events: Events[E]): F[F[SeqNr]] = { - case class State(writes: Int, maxSeqNr: SeqNr) - val state = State(events.values.length, SeqNr.Min) + case class State(writes: Long, maxSeqNr: SeqNr) + val state = State(events.size, SeqNr.Min) val actor = LocalActorRef[F, State, SeqNr](state, timeout) { case (state, JournalProtocol.WriteMessagesSuccessful) => state.asLeft[SeqNr].pure[F] @@ -224,18 +239,25 @@ object PersistenceAdapter { case (_, JournalProtocol.WriteMessageFailure(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] } - val messages = events.values.toList.map { events => - val persistent = events.toList.map { event => - PersistentRepr(event, persistenceId = persistenceId) - } - AtomicWrite(persistent) - } - for { + messages <- appendedSeqNr.modify { seqNr => + var _seqNr = seqNr + def nextSeqNr = { + _seqNr = _seqNr + 1 + _seqNr + } + val messages = events.values.toList.map { events => + val persistent = events.toList.map { event => + PersistentRepr(event, persistenceId = persistenceId, sequenceNr = nextSeqNr) + } + AtomicWrite(persistent) + } + _seqNr -> messages + } actor <- actor request = JournalProtocol.WriteMessages(messages, actor.ref, 0) _ <- journaller.tell(request) - } yield actor.res // TODO: set timeout + } yield actor.res } } @@ -253,7 +275,7 @@ object PersistenceAdapter { actor <- actor request = JournalProtocol.DeleteMessagesTo(persistenceId, seqNr, actor.ref) _ <- journaller.tell(request) - } yield actor.res // TODO: set timeout + } yield actor.res } } diff --git a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala index 4a4cce18..c9eba8fd 100644 --- a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala +++ b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala @@ -17,6 +17,7 @@ import scala.concurrent.duration._ import scala.util.Try import akka.persistence.snapshot.SnapshotStore import akka.pattern.AskTimeoutException +import java.util.concurrent.TimeoutException class PersistenceAdapterTest extends AnyFunSuite with Matchers { @@ -231,6 +232,33 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { io.unsafeRunSync() } + test("journal: replay (nothing), save, replay, delete, replay") { + + val persistenceId = EventSourcedId("#11") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 600.second) + journal <- adapter.journaller[String](emptyPluginId, persistenceId, SeqNr.Min) + events <- journal.replay(SeqNr.Max, Long.MaxValue) + events <- events.toList + _ = events shouldEqual List.empty[Event[String]] + seqNr <- journal.append(Events.of("first", "second")).flatten + _ = seqNr shouldEqual 2L + events <- journal.replay(SeqNr.Max, Long.MaxValue) + events <- events.toList + _ = events shouldEqual List(Event.const("first", 1L), Event.const("second", 2L)) + _ <- journal.deleteTo(1L).flatten + events <- journal.replay(SeqNr.Max, Long.MaxValue) + events <- events.toList + _ = events shouldEqual List(Event.const("second", 2L)) + } yield {} + } + + io.unsafeRunSync() + } + test("journal: fail loading events") { val pluginId = "failing-journal" @@ -240,8 +268,8 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { .use { system => for { adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId) - events <- journal.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) + events <- journal.replay(SeqNr.Max, Long.MaxValue) error <- events.toList.attempt _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] } yield {} @@ -259,7 +287,7 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { .use { system => for { adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId) + journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) seqNr <- journal.append(Events.of[String]("first", "second")) error <- seqNr.attempt _ = error shouldEqual Expected.exception.asLeft[SeqNr] @@ -278,7 +306,7 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { .use { system => for { adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId) + journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) deleting <- journal.deleteTo(SeqNr.Max) error <- deleting.attempt _ = error shouldEqual Expected.exception.asLeft[Unit] @@ -288,24 +316,71 @@ class PersistenceAdapterTest extends AnyFunSuite with Matchers { io.unsafeRunSync() } - // test("journal: timeout on loading events") { + test("journal: timeout on loading events") { + + val pluginId = "infinite-journal" + val persistenceId = EventSourcedId("#14") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) + events <- journal.replay(SeqNr.Max, Long.MaxValue) + error <- events.toList.attempt + } yield error match { + case Left(_: TimeoutException) => succeed + case Left(e) => fail(e) + case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") + } + } + + io.unsafeRunSync() + } + + test("journal: timeout persisting events") { - // val pluginId = "infinite-journal" - // val persistenceId = EventSourcedId("#6") + val pluginId = "infinite-journal" + val persistenceId = EventSourcedId("#15") - // val io = TestActorSystem[IO]("testing", none) - // .use { system => - // for { - // adapter <- PersistenceAdapter.of[IO](system, 1.second) - // journal <- adapter.journaller[String](pluginId, persistenceId) - // events <- journal.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) - // error <- events.toList.attempt - // _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] - // } yield {} - // } + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) + seqNr <- journal.append(Events.of[String]("first", "second")) + error <- seqNr.attempt + } yield error match { + case Left(_: TimeoutException) => succeed + case Left(e) => fail(e) + case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") + } + } + + io.unsafeRunSync() + } + + test("journal: timeout deleting events") { - // io.unsafeRunSync() - // } + val pluginId = "infinite-journal" + val persistenceId = EventSourcedId("#16") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + adapter <- PersistenceAdapter.of[IO](system, 1.second) + journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) + deleting <- journal.deleteTo(SeqNr.Max) + error <- deleting.attempt + } yield error match { + case Left(_: TimeoutException) => succeed + case Left(e) => fail(e) + case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") + } + } + + io.unsafeRunSync() + } } object Expected { From db6848616783124b8ce8eceb49fde3a89e5585b8 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 7 Dec 2023 17:44:15 +0100 Subject: [PATCH 23/29] connect akka interop and EventSourcedPersistence --- .../persistence/EventSourcedStore.scala | 98 ---- .../akka/persistence/PersistenceAdapter.scala | 289 ------------ .../persistence/EventSourcedActorOf.scala | 39 +- .../persistence/EventSourcedStoreOf.scala | 284 ----------- .../persistence/PersistenceAdapterTest.scala | 441 ------------------ .../persistence/EventSourcedActorOfTest.scala | 111 +++-- .../persistence/EventSourcedStoreOfTest.scala | 48 -- 7 files changed, 71 insertions(+), 1239 deletions(-) delete mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala delete mode 100644 persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala delete mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala delete mode 100644 persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala delete mode 100644 persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala deleted file mode 100644 index ad61303a..00000000 --- a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStore.scala +++ /dev/null @@ -1,98 +0,0 @@ -package com.evolutiongaming.akkaeffect.persistence - -import cats.effect.kernel.Resource -import com.evolutiongaming.sstream.Stream - -/** - * Event sourcing persistence API: provides snapshot followed by stream of events - * - * @tparam F effect - * @tparam S snapshot - * @tparam E event - */ -trait EventSourcedStore[F[_], S, E] { - - import EventSourcedStore.Recovery - - /** - * Start recovery by retrieving snapshot (eager, happening on resource allocation) - * and preparing for loading events (lazy op, happens on [[Recovery#events()]] stream materialisation) - * @param id persistent ID - * @return [[Recovery]] represents started recovery, resource will be released upon actor termination - */ - def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] - - /** - * Create [[Journaller]] capable of persisting and deleting events - * @param id persistent ID - * @param seqNr recovered [[SeqNr]] or [[SeqNr.Min]] - * @return resource will be released upon actor termination - */ - def journaller(id: EventSourcedId, - seqNr: SeqNr): Resource[F, Journaller[F, E]] - - /** - * Create [[Snapshotter]] capable of persisting and deleting snapshots - * @param id persistent ID - * @return resource will be released upon actor termination - */ - def snapshotter(id: EventSourcedId): Resource[F, Snapshotter[F, S]] -} - -object EventSourcedStore { - - /** - * Representation of __started__ recovery process: - * snapshot is already loaded in memory (if any) - * while events will be loaded only on materialisation of [[Stream]] - * - * @tparam F effect - * @tparam S snapshot - * @tparam E event - */ - trait Recovery[F[_], S, E] { - - def snapshot: Option[Snapshot[S]] - def events: Stream[F, Event[E]] - - } - - object Recovery { - - private case class Const[F[_], S, E](snapshot: Option[Snapshot[S]], - events: Stream[F, Event[E]]) - extends Recovery[F, S, E] - - def const[F[_], S, E](snapshot: Option[Snapshot[S]], - events: Stream[F, Event[E]]): Recovery[F, S, E] = - Const(snapshot, events) - - } - - def const[F[_], S, E]( - recovery: Recovery[F, S, E], - journaller: Journaller[F, E], - snapshotter: Snapshotter[F, S] - ): EventSourcedStore[F, S, E] = { - - val (r, j, s) = (recovery, journaller, snapshotter) - - new EventSourcedStore[F, S, E] { - - import cats.syntax.all._ - - override def recover(id: EventSourcedId): Resource[F, Recovery[F, S, E]] = - r.pure[Resource[F, *]] - - override def journaller(id: EventSourcedId, - seqNr: SeqNr): Resource[F, Journaller[F, E]] = - j.pure[Resource[F, *]] - - override def snapshotter( - id: EventSourcedId - ): Resource[F, Snapshotter[F, S]] = - s.pure[Resource[F, *]] - } - } - -} diff --git a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala b/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala deleted file mode 100644 index 875bec28..00000000 --- a/persistence/src/main/scala/akka/persistence/PersistenceAdapter.scala +++ /dev/null @@ -1,289 +0,0 @@ -package akka.persistence - -import akka.actor.ActorSystem -import cats.effect.{Async, Ref} -import cats.syntax.all._ -import com.evolutiongaming.akkaeffect.ActorEffect -import com.evolutiongaming.akkaeffect.persistence.{Append, DeleteEventsTo, Event, EventSourcedId, Events, SeqNr, Snapshot} -import com.evolutiongaming.catshelper.CatsHelper._ -import com.evolutiongaming.catshelper.{FromFuture, ToTry} -import com.evolutiongaming.sstream.FoldWhile.FoldWhileOps -import com.evolutiongaming.sstream.Stream - -import java.time.Instant -import scala.concurrent.duration.FiniteDuration -import scala.reflect.ClassTag - -trait PersistenceAdapter[F[_]] { - - def snapshotter[S: ClassTag](snapshotPluginId: String, persistenceId: EventSourcedId): F[PersistenceAdapter.ExtendedSnapshotter[F, S]] - - def journaller[E: ClassTag]( - journalPluginId: String, - persistenceId: EventSourcedId, - currentSeqNr: SeqNr - ): F[PersistenceAdapter.ExtendedJournaller[F, E]] -} - -object PersistenceAdapter { - - trait ExtendedJournaller[F[_], E] extends com.evolutiongaming.akkaeffect.persistence.Journaller[F, E] { - - def replay(toSequenceNr: SeqNr, max: Long): F[Stream[F, Event[E]]] - - } - - trait ExtendedSnapshotter[F[_], S] extends com.evolutiongaming.akkaeffect.persistence.Snapshotter[F, S] { - - def load(criteria: SnapshotSelectionCriteria, toSequenceNr: SeqNr): F[F[Option[Snapshot[S]]]] - - } - - // TODO: set buffer limit - def of[F[_]: Async: ToTry: FromFuture]( - system: ActorSystem, - timeout: FiniteDuration - ): F[PersistenceAdapter[F]] = { - - val F = Async[F] - - F.delay { - Persistence(system) - }.map { persistence => - new PersistenceAdapter[F] { - - override def snapshotter[S: ClassTag](snapshotPluginId: String, eventSourcedId: EventSourcedId): F[ExtendedSnapshotter[F, S]] = - F.delay { - persistence.snapshotStoreFor(snapshotPluginId) - }.map { actorRef => - val snapshotter = ActorEffect.fromActor(actorRef) - - new ExtendedSnapshotter[F, S] { - - val persistenceId = eventSourcedId.value - - override def load(criteria: SnapshotSelectionCriteria, toSequenceNr: SeqNr): F[F[Option[Snapshot[S]]]] = { - - val request = SnapshotProtocol.LoadSnapshot(persistenceId, criteria, toSequenceNr) - snapshotter - .ask(request, timeout) - .map { response => - response.flatMap { - - case SnapshotProtocol.LoadSnapshotResult(snapshot, _) => - snapshot match { - - case Some(offer) => - offer.snapshot.castM[F, S].map { snapshot => - val metadata = Snapshot.Metadata( - offer.metadata.sequenceNr, - Instant.ofEpochMilli(offer.metadata.timestamp) - ) - Snapshot.const(snapshot, metadata).some - } - - case None => none[Snapshot[S]].pure[F] - } - - case SnapshotProtocol.LoadSnapshotFailed(err) => - err.raiseError[F, Option[Snapshot[S]]] - } - } - } - - override def save(seqNr: SeqNr, snapshot: S): F[F[Instant]] = { - val metadata = SnapshotMetadata(persistenceId, seqNr) - val request = SnapshotProtocol.SaveSnapshot(metadata, snapshot) - snapshotter - .ask(request, timeout) - .map { response => - response.flatMap { - case SaveSnapshotSuccess(metadata) => Instant.ofEpochMilli(metadata.timestamp).pure[F] - case SaveSnapshotFailure(_, err) => err.raiseError[F, Instant] - } - - } - } - - override def delete(seqNr: SeqNr): F[F[Unit]] = { - val metadata = SnapshotMetadata(persistenceId, seqNr) - val request = SnapshotProtocol.DeleteSnapshot(metadata) - snapshotter - .ask(request, timeout) - .map { response => - response.flatMap { - case DeleteSnapshotSuccess(_) => ().pure[F] - case DeleteSnapshotFailure(_, err) => err.raiseError[F, Unit] - } - } - } - - override def delete(criteria: SnapshotSelectionCriteria): F[F[Unit]] = { - val request = SnapshotProtocol.DeleteSnapshots(persistenceId, criteria) - snapshotter - .ask(request, timeout) - .map { response => - response.flatMap { - case DeleteSnapshotsSuccess(_) => ().pure[F] - case DeleteSnapshotsFailure(_, err) => err.raiseError[F, Unit] - } - } - } - - override def delete(criteria: com.evolutiongaming.akkaeffect.persistence.Snapshotter.Criteria): F[F[Unit]] = - delete(criteria.asAkka) - - } - } - - override def journaller[E: ClassTag]( - journalPluginId: String, - eventSourcedId: EventSourcedId, - currentSeqNr: SeqNr - ): F[ExtendedJournaller[F, E]] = { - - for { - pluginActorRef <- F.delay { - persistence.journalFor(journalPluginId) - } - appendedSeqNr <- F.ref(currentSeqNr) - } yield { - val journaller = ActorEffect.fromActor(pluginActorRef) - - new ExtendedJournaller[F, E] { - - val persistenceId = eventSourcedId.value - - override def replay(toSequenceNr: SeqNr, max: Long): F[Stream[F, Event[E]]] = { - - def actor(buffer: Ref[F, Vector[Event[E]]]) = - LocalActorRef[F, Unit, SeqNr]({}, timeout) { - - case (_, JournalProtocol.ReplayedMessage(persisted)) => - println(s"journal, replaying: $persisted") - if (persisted.deleted) ().asLeft[SeqNr].pure[F] - else - for { - e <- persisted.payload.castM[F, E] - event = Event.const(e, persisted.sequenceNr) - _ <- buffer.update(_ :+ event) - } yield ().asLeft[SeqNr] - - case (_, JournalProtocol.RecoverySuccess(seqNr)) => - println(s"journal, recovery success $seqNr") - appendedSeqNr.set(seqNr).as(seqNr.asRight[Unit]) - - case (_, JournalProtocol.ReplayMessagesFailure(error)) => error.raiseError[F, Either[Unit, SeqNr]] - } - - for { - buffer <- Ref[F].of(Vector.empty[Event[E]]) - actor <- actor(buffer) - request = JournalProtocol.ReplayMessages(currentSeqNr + 1, toSequenceNr, max, persistenceId, actor.ref) - _ <- journaller.tell(request) - } yield new Stream[F, Event[E]] { - - override def foldWhileM[L, R](l: L)(f: (L, Event[E]) => F[Either[L, R]]): F[Either[L, R]] = - l.asLeft[R] - .tailRecM { - - case Left(l) => - for { - events <- buffer.getAndSet(Vector.empty[Event[E]]) - done <- actor.get - result <- events.foldWhileM(l)(f) - result <- result match { - - case l: Left[L, R] => - done match { - case Some(Right(_)) => l.asRight[Either[L, R]].pure[F] // no more events - case Some(Left(er)) => er.raiseError[F, Either[Either[L, R], Either[L, R]]] // failure - case None => l.asLeft[Either[L, R]].pure[F] // expecting more events - } - - // Right(...), cos user-defined function [[f]] desided to stop consuming stream thus wrapping in Right to break tailRecM loop - case result => result.asRight[Either[L, R]].pure[F] - - } - } yield result - - case result => // cannot happened - result.asRight[Either[L, R]].pure[F] - } - - } - - } - - override def append: Append[F, E] = new Append[F, E] { - - override def apply(events: Events[E]): F[F[SeqNr]] = { - - case class State(writes: Long, maxSeqNr: SeqNr) - val state = State(events.size, SeqNr.Min) - val actor = LocalActorRef[F, State, SeqNr](state, timeout) { - - case (state, JournalProtocol.WriteMessagesSuccessful) => state.asLeft[SeqNr].pure[F] - - case (state, JournalProtocol.WriteMessageSuccess(persistent, _)) => - val seqNr = persistent.sequenceNr max state.maxSeqNr - val result = - if (state.writes == 1) seqNr.asRight[State] - else State(state.writes - 1, seqNr).asLeft[SeqNr] - result.pure[F] - - case (_, JournalProtocol.WriteMessageRejected(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] - - case (_, JournalProtocol.WriteMessagesFailed(error, _)) => error.raiseError[F, Either[State, SeqNr]] - - case (_, JournalProtocol.WriteMessageFailure(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] - } - - for { - messages <- appendedSeqNr.modify { seqNr => - var _seqNr = seqNr - def nextSeqNr = { - _seqNr = _seqNr + 1 - _seqNr - } - val messages = events.values.toList.map { events => - val persistent = events.toList.map { event => - PersistentRepr(event, persistenceId = persistenceId, sequenceNr = nextSeqNr) - } - AtomicWrite(persistent) - } - _seqNr -> messages - } - actor <- actor - request = JournalProtocol.WriteMessages(messages, actor.ref, 0) - _ <- journaller.tell(request) - } yield actor.res - } - - } - - override def deleteTo: DeleteEventsTo[F] = new DeleteEventsTo[F] { - - override def apply(seqNr: SeqNr): F[F[Unit]] = { - - val actor = LocalActorRef[F, Unit, Unit]({}, timeout) { - case (_, DeleteMessagesSuccess(_)) => ().asRight[Unit].pure[F] - case (_, DeleteMessagesFailure(e, _)) => e.raiseError[F, Either[Unit, Unit]] - } - - for { - actor <- actor - request = JournalProtocol.DeleteMessagesTo(persistenceId, seqNr, actor.ref) - _ <- journaller.tell(request) - } yield actor.res - } - - } - } - } - } - } - } - - } -} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index 440f381c..4fc14071 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -11,33 +11,33 @@ import scala.reflect.ClassTag object EventSourcedActorOf { - /** - * Describes lifecycle of entity with regards to event sourcing & PersistentActor - * Lifecycle phases: + /** Describes lifecycle of entity with regards to event sourcing & PersistentActor Lifecycle phases: * - * 1. RecoveryStarted: we have id in place and can decide whether we should continue with recovery - * 2. Recovering : reading snapshot and replaying events - * 3. Receiving : receiving commands and potentially storing events & snapshots - * 4. Termination : triggers all release hooks of allocated resources within previous phases + * 1. RecoveryStarted: we have id in place and can decide whether we should continue with recovery 2. Recovering : reading snapshot and + * replaying events 3. Receiving : receiving commands and potentially storing events & snapshots 4. Termination : triggers all + * release hooks of allocated resources within previous phases * - * @tparam S snapshot - * @tparam E event - * @tparam C command + * @tparam S + * snapshot + * @tparam E + * event + * @tparam C + * command */ type Lifecycle[F[_], S, E, C] = Resource[F, RecoveryStarted[F, S, E, Receive[F, Envelope[C], ActorOf.Stop]]] def actor[F[_]: Async: ToFuture, S, E, C: ClassTag]( eventSourcedOf: EventSourcedOf[F, Lifecycle[F, S, E, C]], - eventSourcedStoreOf: EventSourcedStoreOf[F, S, E], + persistenceOf: EventSourcedPersistenceOf[F, S, E] ): Actor = ActorOf[F] { actorCtx => for { - eventSourced <- eventSourcedOf(actorCtx).toResource - persistentId = eventSourced.eventSourcedId + eventSourced <- eventSourcedOf(actorCtx).toResource + persistentId = eventSourced.eventSourcedId recoveryStarted <- eventSourced.value - store <- eventSourcedStoreOf(eventSourced) - recovery <- store.recover(persistentId) + persistence <- persistenceOf(eventSourced).toResource + recovery <- persistence.recover.toResource recovering <- recoveryStarted( recovery.snapshot.map(_.metadata.seqNr).getOrElse(SeqNr.Min), @@ -58,14 +58,13 @@ object EventSourcedActorOf { } yield seqNr }.toResource - journaller <- store.journaller(persistentId, seqNr) - snapshotter <- store.snapshotter(persistentId) - receive <- recovering.completed(seqNr, journaller, snapshotter) + journaller <- persistence.journaller(seqNr).toResource + snapshotter <- persistence.snapshotter.toResource + receive <- recovering.completed(seqNr, journaller, snapshotter) } yield receive.contramapM[Envelope[Any]](_.cast[F, C]) } - private implicit class SnapshotOps[S](val snapshot: Snapshot[S]) - extends AnyVal { + implicit private class SnapshotOps[S](val snapshot: Snapshot[S]) extends AnyVal { def asOffer: SnapshotOffer[S] = SnapshotOffer( diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala deleted file mode 100644 index 9ab33458..00000000 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOf.scala +++ /dev/null @@ -1,284 +0,0 @@ -package com.evolutiongaming.akkaeffect.persistence - -import akka.actor.ExtendedActorSystem -import akka.persistence.journal.AsyncWriteJournal -import akka.persistence.snapshot.SnapshotStore -import akka.persistence.{AtomicWrite, PersistentRepr, SnapshotSelectionCriteria} -import cats.effect._ -import cats.effect.implicits.effectResourceOps -import cats.syntax.all._ -import com.evolutiongaming.catshelper.{FromFuture, ToTry} -import com.evolutiongaming.sstream.FoldWhile._ -import com.evolutiongaming.sstream.Stream - -import java.time.Instant -import scala.concurrent.Future -import scala.util.Try - -trait EventSourcedStoreOf[F[_], S, E] { - - def apply( - eventSourced: EventSourced[_] - ): Resource[F, EventSourcedStore[F, S, E]] - -} - -object EventSourcedStoreOf { - - def const[F[_], S, E]( - store: EventSourcedStore[F, S, E] - ): EventSourcedStoreOf[F, S, E] = - _ => store.pure[Resource[F, *]] - - /** - * Create [[EventSourcedStoreOf]] capable of creating [[EventSourcedStore]] - * on top of akka persistence and snapshot plugins defined in [[EventSourced.pluginIds]] - * @param system akka system - * @tparam F effect type - * @return instance of [[EventSourcedStoreOf]] - */ - def fromAkka[F[_]: Async: ToTry, S, E]( - system: ExtendedActorSystem - ): F[EventSourcedStoreOf[F, S, E]] = ??? - - /** - * [[EventSourcedStore]] implementation based on Akka Persistence API. - * - * The implementation delegates snapshot and events load to [[SnapshotStore]] and [[AsyncWriteJournal]]. - * Snapshot loaded on [[EventSourcedStore#recover]] F while events loaded lazily: - * first events will be available for [[Stream#foldWhileM]] while tail still loaded by [[AsyncWriteJournal]] - * - * @param snapshotStore Akka Persistence snapshot (plugin) - * @param asyncJournal Akka Persistence journal (plugin) - * @tparam F effect - * @tparam S snapshot - * @tparam E event - * @return resource of [[EventSourcedStore]] - */ - private[persistence] def fromAkkaPlugins[F[_]: Async: ToTry, S, E]( - snapshotStore: SnapshotStore, - asyncJournal: AsyncWriteJournal - ): Resource[F, EventSourcedStore[F, S, E]] = { - - val eventSourcedStore = new EventSourcedStore[F, S, E] { - - override def recover( - id: EventSourcedId - ): Resource[F, EventSourcedStore.Recovery[F, S, E]] = { - - snapshotStore - .loadAsync(id.value, SnapshotSelectionCriteria()) - .liftTo[F] - .toResource - .map { offer => - new EventSourcedStore.Recovery[F, S, E] { - - override val snapshot: Option[Snapshot[S]] = - offer.map { offer => - new Snapshot[S] { - override def snapshot: S = offer.snapshot.asInstanceOf[S] - - override def metadata: Snapshot.Metadata = - Snapshot.Metadata( - seqNr = offer.metadata.sequenceNr, - timestamp = - Instant.ofEpochMilli(offer.metadata.timestamp) - ) - } - } - - override val events: Stream[F, Event[E]] = { - val fromSequenceNr = - snapshot.map(_.metadata.seqNr).getOrElse(0L) - - val stream = for { - - buffer <- Ref[F].of(Vector.empty[Event[E]]) - - highestSequenceNr <- asyncJournal - .asyncReadHighestSequenceNr(id.value, fromSequenceNr) - .liftTo[F] - - replayed <- Sync[F].delay { - - asyncJournal.asyncReplayMessages( - id.value, - fromSequenceNr, - highestSequenceNr, - Long.MaxValue - ) { persisted => - if (persisted.deleted) {} else { - val event = new Event[E] { - override val event: E = - persisted.payload.asInstanceOf[E] - override val seqNr: SeqNr = - persisted.sequenceNr - } - val _ = buffer.update(_ :+ event).toTry - } - } - - } - } yield { - - new Stream[F, Event[E]] { - - override def foldWhileM[L, R]( - l: L - )(f: (L, Event[E]) => F[Either[L, R]]): F[Either[L, R]] = { - - l.asLeft[R] - .tailRecM { - case Left(l) => - for { - events <- buffer.getAndSet(Vector.empty[Event[E]]) - result <- events.foldWhileM(l)(f) - result <- result match { - - case l: Left[L, R] => - for { - replayed <- Sync[F].delay( - replayed.isCompleted - ) - } yield - if (replayed) l.asRight[Either[L, R]] - else l.asLeft[Either[L, R]] - - case result => - result.asRight[Either[L, R]].pure[F] - - } - } yield result - - case result => result.asRight[Either[L, R]].pure[F] - } - } - - } - } - - Stream.lift(stream).flatten - } - } - } - } - - override def journaller(id: EventSourcedId, - seqNr: SeqNr): Resource[F, Journaller[F, E]] = { - - def journaller(seqNr: Ref[F, SeqNr]) = new Journaller[F, E] { - - override def append: Append[F, E] = new Append[F, E] { - - override def apply(events: Events[E]): F[F[SeqNr]] = { - - val atomicWrites = events.values.toList.map { events => - val persistent = events.toList.map { event => - PersistentRepr(event, persistenceId = id.value) - } - AtomicWrite(persistent) - } - - seqNr - .updateAndGet(_ + events.size) - .flatMap { seqNr => - Sync[F].delay { - - asyncJournal - .asyncWriteMessages(atomicWrites) - .liftTo[F] - .flatMap { results => - results.sequence - .liftTo[F] - .as(seqNr) - } - - } - } - } - } - - override def deleteTo: DeleteEventsTo[F] = new DeleteEventsTo[F] { - - override def apply(seqNr: SeqNr): F[F[Unit]] = { - - Sync[F].delay { - asyncJournal - .asyncDeleteMessagesTo(id.value, seqNr) - .liftTo[F] - - } - } - } - } - - Ref[F] - .of(seqNr) - .map(journaller) - .toResource - - } - - override def snapshotter( - id: EventSourcedId - ): Resource[F, Snapshotter[F, S]] = { - - val snapshotter = new Snapshotter[F, S] { - - override def save(seqNr: SeqNr, snapshot: S): F[F[Instant]] = { - for { - timestamp <- Clock[F].realTimeInstant - metadata = akka.persistence.SnapshotMetadata( - id.value, - seqNr, - timestamp.toEpochMilli - ) - saving <- Sync[F].delay { - snapshotStore - .saveAsync(metadata, snapshot) - .liftTo[F] - } - } yield saving as timestamp - } - - override def delete(seqNr: SeqNr): F[F[Unit]] = { - Sync[F].delay { - val metadata = akka.persistence.SnapshotMetadata(id.value, seqNr) - snapshotStore.deleteAsync(metadata).liftTo[F] - } - } - - override def delete( - criteria: SnapshotSelectionCriteria - ): F[F[Unit]] = { - Sync[F].delay { - snapshotStore.deleteAsync(id.value, criteria).liftTo[F] - } - } - - override def delete(criteria: Snapshotter.Criteria): F[F[Unit]] = { - Sync[F].delay { - snapshotStore.deleteAsync(id.value, criteria.asAkka).liftTo[F] - } - } - - } - - snapshotter.pure[Resource[F, *]] - - } - } - - eventSourcedStore.pure[Resource[F, *]] - - } - - implicit class FromFutureSyntax[A](val future: Future[A]) extends AnyVal { - def liftTo[F[_]: FromFuture]: F[A] = FromFuture[F].apply(future) - } - - implicit class ToTrySyntax[F[_], A](val fa: F[A]) extends AnyVal { - def toTry(implicit F: ToTry[F]): Try[A] = F(fa) - } - -} diff --git a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala b/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala deleted file mode 100644 index c9eba8fd..00000000 --- a/persistence/src/test/scala/akka/persistence/PersistenceAdapterTest.scala +++ /dev/null @@ -1,441 +0,0 @@ -package akka.persistence - -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -import cats.syntax.all._ -import cats.effect.IO -import cats.effect.unsafe.implicits.global - -import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr, Event, Events, Snapshotter} -import com.evolutiongaming.akkaeffect.testkit.TestActorSystem - -import scala.util.Random -import akka.persistence.journal.AsyncWriteJournal -import scala.concurrent.Future -import scala.concurrent.duration._ -import scala.util.Try -import akka.persistence.snapshot.SnapshotStore -import akka.pattern.AskTimeoutException -import java.util.concurrent.TimeoutException - -class PersistenceAdapterTest extends AnyFunSuite with Matchers { - - val emptyPluginId = "" - - test("snapshot: load, save and load again") { - - val persistenceId = EventSourcedId("#1") - val payload = Random.nextString(1024) - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](emptyPluginId, persistenceId) - snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten - _ = snapshot shouldEqual none - _ <- snapshotter.save(SeqNr.Min, payload).flatten - snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten - _ = snapshot.get.snapshot should equal(payload) - } yield {} - } - - io.unsafeRunSync() - } - - test("snapshot: save, load, delete and load again") { - - val persistenceId = EventSourcedId("#2") - val payload = Random.nextString(1024) - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](emptyPluginId, persistenceId) - _ <- snapshotter.save(SeqNr.Min, payload).flatten - snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten - _ = snapshot.get.snapshot should equal(payload) - _ <- snapshotter.delete(SeqNr.Min).flatten - snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten - _ = snapshot shouldEqual none - } yield {} - } - - io.unsafeRunSync() - } - - test("snapshot: fail load snapshot") { - - val pluginId = "failing-snapshot" - val persistenceId = EventSourcedId("#3") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue) - error <- snapshot.attempt - _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] - } yield {} - } - - io.unsafeRunSync() - } - - test("snapshot: fail save snapshot") { - - val pluginId = "failing-snapshot" - val persistenceId = EventSourcedId("#4") - val payload = Random.nextString(1024) - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - saving <- snapshotter.save(SeqNr.Min, payload) - error <- saving.attempt - _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] - } yield {} - } - - io.unsafeRunSync() - } - - test("snapshot: fail delete snapshot") { - - val pluginId = "failing-snapshot" - val persistenceId = EventSourcedId("#5") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - deleting <- snapshotter.delete(SeqNr.Min) - error <- deleting.attempt - _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] - } yield {} - } - - io.unsafeRunSync() - } - - test("snapshot: fail delete snapshot by criteria") { - - val pluginId = "failing-snapshot" - val persistenceId = EventSourcedId("#6") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - deleting <- snapshotter.delete(Snapshotter.Criteria()) - error <- deleting.attempt - _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] - } yield {} - } - - io.unsafeRunSync() - } - - test("snapshot: timeout loading snapshot") { - - val pluginId = "infinite-snapshot" - val persistenceId = EventSourcedId("#7") - - val io = TestActorSystem[IO]("testing", none) - .use(system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue) - error <- snapshot.attempt - } yield error match { - case Left(_: AskTimeoutException) => succeed - case Left(other) => fail(other) - case Right(_) => fail("the test should fail with AskTimeoutException but did no") - } - ) - - io.unsafeRunSync() - } - - test("snapshot: timeout saving snapshot") { - - val pluginId = "infinite-snapshot" - val persistenceId = EventSourcedId("#8") - val payload = Random.nextString(1024) - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - saving <- snapshotter.save(SeqNr.Min, payload) - error <- saving.attempt - } yield error match { - case Left(_: AskTimeoutException) => succeed - case Left(other) => fail(other) - case Right(_) => fail("the test should fail with AskTimeoutException but did no") - } - } - - io.unsafeRunSync() - } - - test("snapshot: timeout deleting snapshot") { - - val pluginId = "infinite-snapshot" - val persistenceId = EventSourcedId("#9") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - deleting <- snapshotter.delete(SeqNr.Min) - error <- deleting.attempt - } yield error match { - case Left(_: AskTimeoutException) => succeed - case Left(other) => fail(other) - case Right(_) => fail("the test should fail with AskTimeoutException but did no") - } - } - - io.unsafeRunSync() - } - - test("snapshot: timeout deleting snapshot by criteria") { - - val pluginId = "infinite-snapshot" - val persistenceId = EventSourcedId("#10") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - snapshotter <- adapter.snapshotter[String](pluginId, persistenceId) - deleting <- snapshotter.delete(Snapshotter.Criteria()) - error <- deleting.attempt - } yield error match { - case Left(_: AskTimeoutException) => succeed - case Left(other) => fail(other) - case Right(_) => fail("the test should fail with AskTimeoutException but did no") - } - } - - io.unsafeRunSync() - } - - test("journal: replay (nothing), save, replay, delete, replay") { - - val persistenceId = EventSourcedId("#11") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 600.second) - journal <- adapter.journaller[String](emptyPluginId, persistenceId, SeqNr.Min) - events <- journal.replay(SeqNr.Max, Long.MaxValue) - events <- events.toList - _ = events shouldEqual List.empty[Event[String]] - seqNr <- journal.append(Events.of("first", "second")).flatten - _ = seqNr shouldEqual 2L - events <- journal.replay(SeqNr.Max, Long.MaxValue) - events <- events.toList - _ = events shouldEqual List(Event.const("first", 1L), Event.const("second", 2L)) - _ <- journal.deleteTo(1L).flatten - events <- journal.replay(SeqNr.Max, Long.MaxValue) - events <- events.toList - _ = events shouldEqual List(Event.const("second", 2L)) - } yield {} - } - - io.unsafeRunSync() - } - - test("journal: fail loading events") { - - val pluginId = "failing-journal" - val persistenceId = EventSourcedId("#11") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) - events <- journal.replay(SeqNr.Max, Long.MaxValue) - error <- events.toList.attempt - _ = error shouldEqual Expected.exception.asLeft[List[Event[String]]] - } yield {} - } - - io.unsafeRunSync() - } - - test("journal: fail persisting events") { - - val pluginId = "failing-journal" - val persistenceId = EventSourcedId("#12") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) - seqNr <- journal.append(Events.of[String]("first", "second")) - error <- seqNr.attempt - _ = error shouldEqual Expected.exception.asLeft[SeqNr] - } yield {} - } - - io.unsafeRunSync() - } - - test("journal: fail deleting events") { - - val pluginId = "failing-journal" - val persistenceId = EventSourcedId("#13") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) - deleting <- journal.deleteTo(SeqNr.Max) - error <- deleting.attempt - _ = error shouldEqual Expected.exception.asLeft[Unit] - } yield {} - } - - io.unsafeRunSync() - } - - test("journal: timeout on loading events") { - - val pluginId = "infinite-journal" - val persistenceId = EventSourcedId("#14") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) - events <- journal.replay(SeqNr.Max, Long.MaxValue) - error <- events.toList.attempt - } yield error match { - case Left(_: TimeoutException) => succeed - case Left(e) => fail(e) - case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") - } - } - - io.unsafeRunSync() - } - - test("journal: timeout persisting events") { - - val pluginId = "infinite-journal" - val persistenceId = EventSourcedId("#15") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) - seqNr <- journal.append(Events.of[String]("first", "second")) - error <- seqNr.attempt - } yield error match { - case Left(_: TimeoutException) => succeed - case Left(e) => fail(e) - case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") - } - } - - io.unsafeRunSync() - } - - test("journal: timeout deleting events") { - - val pluginId = "infinite-journal" - val persistenceId = EventSourcedId("#16") - - val io = TestActorSystem[IO]("testing", none) - .use { system => - for { - adapter <- PersistenceAdapter.of[IO](system, 1.second) - journal <- adapter.journaller[String](pluginId, persistenceId, SeqNr.Min) - deleting <- journal.deleteTo(SeqNr.Max) - error <- deleting.attempt - } yield error match { - case Left(_: TimeoutException) => succeed - case Left(e) => fail(e) - case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") - } - } - - io.unsafeRunSync() - } -} - -object Expected { - val exception = new RuntimeException("test exception") -} - -class FailingJournal extends AsyncWriteJournal { - - override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)( - recoveryCallback: PersistentRepr => Unit - ): Future[Unit] = Future.failed(Expected.exception) - - override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = Future.failed(Expected.exception) - - override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = Future.failed(Expected.exception) - - override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.failed(Expected.exception) - -} - -class InfiniteJournal extends AsyncWriteJournal { - - override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)( - recoveryCallback: PersistentRepr => Unit - ): Future[Unit] = Future.never - - override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = Future.never - - override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = Future.never - - override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.never - -} - -class FailingSnapshotter extends SnapshotStore { - - override def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = - Future.failed(Expected.exception) - - override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit] = Future.failed(Expected.exception) - - override def deleteAsync(metadata: SnapshotMetadata): Future[Unit] = Future.failed(Expected.exception) - - override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = Future.failed(Expected.exception) - -} - -class InfiniteSnapshotter extends SnapshotStore { - - override def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = Future.never - - override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit] = Future.never - - override def deleteAsync(metadata: SnapshotMetadata): Future[Unit] = Future.never - - override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = Future.never - -} diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala index 22e512fa..2441b876 100644 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOfTest.scala @@ -1,7 +1,6 @@ package com.evolutiongaming.akkaeffect.persistence import akka.actor.Props -import cats.effect.implicits.effectResourceOps import cats.effect.unsafe.implicits.global import cats.effect.{Async, IO, Ref, Resource} import cats.syntax.all._ @@ -15,10 +14,7 @@ import org.scalatest.wordspec.AnyWordSpec import java.time.Instant import scala.concurrent.duration._ -class EventSourcedActorOfTest - extends AnyWordSpec - with ActorSuite - with Matchers { +class EventSourcedActorOfTest extends AnyWordSpec with ActorSuite with Matchers { type F[A] = IO[A] val F = Async[F] @@ -33,7 +29,7 @@ class EventSourcedActorOfTest val actorRefOf = ActorRefOf.fromActorRefFactory[F](actorSystem) val timestamp = Instant.ofEpochMilli(0) // due to hardcoded value in InstrumentEventSourced #45 - val timeout = 1.second + val timeout = 1.second "recover" when { @@ -72,12 +68,12 @@ class EventSourcedActorOfTest "no snapshots and no events" should { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - EventSourcedStore.const( - recovery = EventSourcedStore.Recovery.const(none, Stream.empty), + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + EventSourcedPersistenceOf.const { + EventSourcedPersistence.const( + recovery = EventSourcedPersistence.Recovery.const(none, Stream.empty), journaller = Journaller.empty[F, E], - snapshotter = Snapshotter.empty[F, S], + snapshotter = Snapshotter.empty[F, S] ) } @@ -94,8 +90,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) @@ -128,17 +124,17 @@ class EventSourcedActorOfTest "no snapshots and few events" should { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - EventSourcedStore.const( - recovery = EventSourcedStore.Recovery.const( + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + EventSourcedPersistenceOf.const { + EventSourcedPersistence.const( + recovery = EventSourcedPersistence.Recovery.const( none, Stream.from[F, List, Event[E]]( List(Event.const("first", 1L), Event.const("second", 2L)) ) ), journaller = Journaller.empty[F, E], - snapshotter = Snapshotter.empty[F, S], + snapshotter = Snapshotter.empty[F, S] ) } @@ -155,8 +151,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) @@ -191,15 +187,15 @@ class EventSourcedActorOfTest "there's snapshot and no events" should { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - EventSourcedStore.const( - recovery = EventSourcedStore.Recovery.const( + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + EventSourcedPersistenceOf.const { + EventSourcedPersistence.const( + recovery = EventSourcedPersistence.Recovery.const( Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, Stream.empty ), journaller = Journaller.empty[F, E], - snapshotter = Snapshotter.empty[F, S], + snapshotter = Snapshotter.empty[F, S] ) } @@ -216,8 +212,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) @@ -253,17 +249,17 @@ class EventSourcedActorOfTest "there's snapshot and few event" should { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - EventSourcedStore.const( - recovery = EventSourcedStore.Recovery.const( + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + EventSourcedPersistenceOf.const { + EventSourcedPersistence.const( + recovery = EventSourcedPersistence.Recovery.const( Snapshot.const(0, Snapshot.Metadata(0L, timestamp)).some, Stream.from[F, List, Event[E]]( List(Event.const("first", 1L), Event.const("second", 2L)) ) ), journaller = Journaller.empty[F, E], - snapshotter = Snapshotter.empty[F, S], + snapshotter = Snapshotter.empty[F, S] ) } @@ -280,15 +276,15 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) bar <- effect.ask("foo", timeout).flatten - _ = bar shouldEqual "bar" + _ = bar shouldEqual "bar" foo <- effect.ask("bar", timeout).flatten - _ = foo shouldEqual "foo" + _ = foo shouldEqual "foo" _ <- effect.tell("die") _ <- terminated @@ -337,8 +333,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) @@ -380,8 +376,8 @@ class EventSourcedActorOfTest "exception" should { "be raised from EventSourcedStoreOf[F].apply, ie on loading Akka plugins in EventSourcedStoreOf.fromAkka" in { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - _ => F.raiseError(new RuntimeException()).toResource + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + _ => F.raiseError(new RuntimeException()) Probe .of(actorRefOf) @@ -394,8 +390,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) @@ -421,17 +417,14 @@ class EventSourcedActorOfTest } "be raised from EventSourcedStore[F].recover, ie on loading snapshot" in { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - new EventSourcedStore[F, S, E] { - override def recover(id: EventSourcedId) = - F.raiseError(new RuntimeException()).toResource + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + EventSourcedPersistenceOf.const { + new EventSourcedPersistence[F, S, E] { + override def recover = F.raiseError(new RuntimeException()) - override def journaller(id: EventSourcedId, seqNr: SeqNr) = - Journaller.empty[F, E].pure[Resource[F, *]] + override def journaller(seqNr: SeqNr) = Journaller.empty[F, E].pure[F] - override def snapshotter(id: EventSourcedId) = - Snapshotter.empty[F, S].pure[Resource[F, *]] + override def snapshotter = Snapshotter.empty[F, S].pure[F] } } @@ -446,8 +439,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) @@ -473,10 +466,10 @@ class EventSourcedActorOfTest } "be raised on materialisation of Stream provided by Recovery[F].events', ie on loading events" in { - val eventSourcedStoreOf: EventSourcedStoreOf[F, S, E] = - EventSourcedStoreOf.const { - EventSourcedStore.const( - recovery = EventSourcedStore.Recovery + val eventSourcedStoreOf: EventSourcedPersistenceOf[F, S, E] = + EventSourcedPersistenceOf.const { + EventSourcedPersistence.const( + recovery = EventSourcedPersistence.Recovery .const(none, Stream.lift(F.raiseError(new RuntimeException()))), journaller = Journaller.empty[F, E], snapshotter = Snapshotter.empty[F, S] @@ -494,8 +487,8 @@ class EventSourcedActorOfTest props = Props( EventSourcedActorOf.actor(eventSourcedOf, eventSourcedStoreOf) ) - actor <- F.delay { actorSystem.actorOf(props) } - effect <- ActorEffect.fromActor[F](actor).pure[F] + actor <- F.delay(actorSystem.actorOf(props)) + effect <- ActorEffect.fromActor[F](actor).pure[F] terminated <- probe.watch(actor) _ <- effect.tell(akka.actor.ReceiveTimeout) diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala deleted file mode 100644 index 03e67c22..00000000 --- a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedStoreOfTest.scala +++ /dev/null @@ -1,48 +0,0 @@ -package com.evolutiongaming.akkaeffect.persistence - -import akka.actor.ExtendedActorSystem -import cats.effect.implicits.effectResourceOps -import cats.syntax.all._ -import cats.effect.{Async, IO, Resource} -import cats.effect.unsafe.implicits.global -import com.evolutiongaming.akkaeffect.testkit.TestActorSystem -import com.evolutiongaming.catshelper.ToTry -import com.evolutiongaming.catshelper.CatsHelper._ -import org.scalatest.funsuite.AnyFunSuite -import org.scalatest.matchers.should.Matchers - -class EventSourcedStoreOfTest extends AnyFunSuite with Matchers { - - type F[A] = IO[A] - val F = Async[F] - - implicit val toTry = ToTry.ioToTry(global) - - type S = String - type E = String - - ignore("""EventSourcedStoreOf.fromAkka can use journal & snapshot plugins - |defined in Akka conf (in our case `test.conf`) - |without specifying plugin IDs in EventSourced.pluginIds""".stripMargin) { - - val id = EventSourcedId("id #1") - val es = EventSourced[Unit](id, value = {}) - - val resource = for { - as <- TestActorSystem[F]("testing", none) - as <- as.castM[Resource[F, *], ExtendedActorSystem] - of <- EventSourcedStoreOf.fromAkka[F, S, E](as).toResource - - store <- of(es) - - recovery0 <- store.recover(id) - _ = recovery0.snapshot shouldEqual none - - // TODO: finish me! - - } yield {} - - resource.use_.unsafeRunSync() - } - -} From 7e804880fd0784787c5e9c2849af056f21eaa182 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 7 Dec 2023 17:48:14 +0100 Subject: [PATCH 24/29] add all new classes --- .../persistence/EventSourcedPersistence.scala | 85 ++++++ .../ExtendedSnapshoterInterop.scala | 115 ++++++++ .../akka/persistence/JournallerInterop.scala | 109 +++++++ .../akka/persistence/ReplayerInterop.scala | 102 +++++++ .../persistence/EventSourcedActorOf.scala | 1 - .../EventSourcedPersistenceOf.scala | 79 ++++++ .../persistence/ExtendedSnapshotter.scala | 21 ++ .../akkaeffect/persistence/JournallerOf.scala | 7 + .../akkaeffect/persistence/Replayer.scala | 19 ++ .../ExtendedSnapshoterInteropTest.scala | 265 ++++++++++++++++++ .../JournallerAndReplayerInteropTest.scala | 205 ++++++++++++++ 11 files changed, 1007 insertions(+), 1 deletion(-) create mode 100644 persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistence.scala create mode 100644 persistence/src/main/scala/akka/persistence/ExtendedSnapshoterInterop.scala create mode 100644 persistence/src/main/scala/akka/persistence/JournallerInterop.scala create mode 100644 persistence/src/main/scala/akka/persistence/ReplayerInterop.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/ExtendedSnapshotter.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/JournallerOf.scala create mode 100644 persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Replayer.scala create mode 100644 persistence/src/test/scala/akka/persistence/ExtendedSnapshoterInteropTest.scala create mode 100644 persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala diff --git a/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistence.scala b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistence.scala new file mode 100644 index 00000000..fee1b168 --- /dev/null +++ b/persistence-api/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistence.scala @@ -0,0 +1,85 @@ +package com.evolutiongaming.akkaeffect.persistence + +import com.evolutiongaming.sstream.Stream +import cats.Applicative +import cats.syntax.all._ + +/** Event sourcing persistence API: provides snapshot followed by stream of events. After recovery completed, provides instances of + * [[Journaller]] and [[Snapshotter]]. + * + * @tparam F + * Effect type. + * @tparam S + * Snapshot type. + * @tparam E + * Event type. + */ +trait EventSourcedPersistence[F[_], S, E] { + + import EventSourcedPersistence.Recovery + + /** Start recovery by retrieving snapshot (eager, happening on [[F]]) and preparing for loading events (lazy op, happens on + * [[Recovery#events()]] stream materialisation). + * @return + * Instance of [[Recovery]] that represents started recovery. + */ + def recover: F[Recovery[F, S, E]] + + /** Create [[Journaller]] capable of persisting and deleting events. + * @param seqNr + * Recovered [[SeqNr]] or [[SeqNr.Min]] if nothing was recovered. + */ + def journaller(seqNr: SeqNr): F[Journaller[F, E]] + + /** Create [[Snapshotter]] capable of persisting and deleting snapshots. + */ + def snapshotter: F[Snapshotter[F, S]] +} + +object EventSourcedPersistence { + + /** Representation of __started__ recovery process: snapshot is already loaded in memory (if any) while events will be loaded only on + * materialisation of [[Stream]] + * + * @tparam F + * effect + * @tparam S + * snapshot + * @tparam E + * event + */ + trait Recovery[F[_], S, E] { + + def snapshot: Option[Snapshot[S]] + def events: Stream[F, Event[E]] + + } + + object Recovery { + + private case class Const[F[_], S, E](snapshot: Option[Snapshot[S]], events: Stream[F, Event[E]]) extends Recovery[F, S, E] + + def const[F[_], S, E](snapshot: Option[Snapshot[S]], events: Stream[F, Event[E]]): Recovery[F, S, E] = + Const(snapshot, events) + + } + + def const[F[_]: Applicative, S, E]( + recovery: Recovery[F, S, E], + journaller: Journaller[F, E], + snapshotter: Snapshotter[F, S] + ): EventSourcedPersistence[F, S, E] = { + + val (r, j, s) = (recovery, journaller, snapshotter) + + new EventSourcedPersistence[F, S, E] { + + override def recover: F[Recovery[F, S, E]] = r.pure[F] + + override def journaller(seqNr: SeqNr): F[Journaller[F, E]] = j.pure[F] + + override def snapshotter: F[Snapshotter[F, S]] = s.pure[F] + } + } + +} diff --git a/persistence/src/main/scala/akka/persistence/ExtendedSnapshoterInterop.scala b/persistence/src/main/scala/akka/persistence/ExtendedSnapshoterInterop.scala new file mode 100644 index 00000000..5d9a7dc3 --- /dev/null +++ b/persistence/src/main/scala/akka/persistence/ExtendedSnapshoterInterop.scala @@ -0,0 +1,115 @@ +package akka.persistence + +import akka.actor.ActorSystem + +import cats.syntax.all._ +import cats.effect.Sync + +import com.evolutiongaming.catshelper.FromFuture +import com.evolutiongaming.akkaeffect.ActorEffect +import com.evolutiongaming.akkaeffect.persistence.{ExtendedSnapshotter, EventSourcedId, SeqNr, Snapshot} + +import scala.concurrent.duration._ +import java.time.Instant + +object ExtendedSnapshoterInterop { + + def apply[F[_]: Sync: FromFuture]( + system: ActorSystem, + timeout: FiniteDuration + ): F[ExtendedSnapshotter.Of[F]] = + Sync[F] + .delay { + Persistence(system) + } + .map { persistence => + new ExtendedSnapshotter.Of[F] { + + override def apply[S](snapshotPluginId: String, eventSourcedId: EventSourcedId): F[ExtendedSnapshotter[F, S]] = + Sync[F] + .delay { + val ref = persistence.snapshotStoreFor(snapshotPluginId) + ActorEffect.fromActor(ref) + } + .map { actor => + new ExtendedSnapshotter[F, S] { + + val persistenceId = eventSourcedId.value + + override def load(criteria: SnapshotSelectionCriteria, toSequenceNr: SeqNr): F[F[Option[Snapshot[S]]]] = { + + val request = SnapshotProtocol.LoadSnapshot(persistenceId, criteria, toSequenceNr) + actor + .ask(request, timeout) + .map { response => + response.flatMap { + + case SnapshotProtocol.LoadSnapshotResult(snapshot, _) => + snapshot match { + + case Some(offer) => + val payload = offer.snapshot.asInstanceOf[S] + val metadata = Snapshot.Metadata( + offer.metadata.sequenceNr, + Instant.ofEpochMilli(offer.metadata.timestamp) + ) + Snapshot.const(payload, metadata).some.pure[F] + + case None => none[Snapshot[S]].pure[F] + } + + case SnapshotProtocol.LoadSnapshotFailed(err) => + err.raiseError[F, Option[Snapshot[S]]] + } + } + } + + override def save(seqNr: SeqNr, snapshot: S): F[F[Instant]] = { + val metadata = SnapshotMetadata(persistenceId, seqNr) + val request = SnapshotProtocol.SaveSnapshot(metadata, snapshot) + actor + .ask(request, timeout) + .map { response => + response.flatMap { + case SaveSnapshotSuccess(metadata) => Instant.ofEpochMilli(metadata.timestamp).pure[F] + case SaveSnapshotFailure(_, err) => err.raiseError[F, Instant] + } + + } + } + + override def delete(seqNr: SeqNr): F[F[Unit]] = { + val metadata = SnapshotMetadata(persistenceId, seqNr) + val request = SnapshotProtocol.DeleteSnapshot(metadata) + actor + .ask(request, timeout) + .map { response => + response.flatMap { + case DeleteSnapshotSuccess(_) => ().pure[F] + case DeleteSnapshotFailure(_, err) => err.raiseError[F, Unit] + } + } + } + + override def delete(criteria: SnapshotSelectionCriteria): F[F[Unit]] = { + val request = SnapshotProtocol.DeleteSnapshots(persistenceId, criteria) + actor + .ask(request, timeout) + .map { response => + response.flatMap { + case DeleteSnapshotsSuccess(_) => ().pure[F] + case DeleteSnapshotsFailure(_, err) => err.raiseError[F, Unit] + } + } + } + + override def delete(criteria: com.evolutiongaming.akkaeffect.persistence.Snapshotter.Criteria): F[F[Unit]] = + delete(criteria.asAkka) + + } + } + + } + + } +} diff --git a/persistence/src/main/scala/akka/persistence/JournallerInterop.scala b/persistence/src/main/scala/akka/persistence/JournallerInterop.scala new file mode 100644 index 00000000..f70fea1d --- /dev/null +++ b/persistence/src/main/scala/akka/persistence/JournallerInterop.scala @@ -0,0 +1,109 @@ +package akka.persistence + +import akka.actor.ActorSystem + +import cats.syntax.all._ +import cats.effect.Async + +import com.evolutiongaming.catshelper.{ToTry, FromFuture} +import com.evolutiongaming.akkaeffect.ActorEffect +import com.evolutiongaming.akkaeffect.persistence.{JournallerOf, EventSourcedId, Journaller, Events, SeqNr} + +import scala.concurrent.duration._ +import com.evolutiongaming.akkaeffect.persistence.Append +import com.evolutiongaming.akkaeffect.persistence.DeleteEventsTo + +object JournallerInterop { + + def apply[F[_]: Async: ToTry: FromFuture]( + system: ActorSystem, + timeout: FiniteDuration + ): F[JournallerOf[F]] = + Async[F] + .delay { + Persistence(system) + } + .map { persistence => + new JournallerOf[F] { + + val F = Async[F] + + override def apply[E](journalPluginId: String, eventSourcedId: EventSourcedId, currentSeqNr: SeqNr): F[Journaller[F, E]] = + for { + actorRef <- F.delay(persistence.journalFor(journalPluginId)) + journaller <- F.delay(ActorEffect.fromActor(actorRef)) + appendedSeqNr <- F.ref(currentSeqNr) + } yield new Journaller[F, E] { + + val persistenceId = eventSourcedId.value + + override def append: Append[F, E] = new Append[F, E] { + + override def apply(events: Events[E]): F[F[SeqNr]] = { + + case class State(writes: Long, maxSeqNr: SeqNr) + val state = State(events.size, SeqNr.Min) + val actor = LocalActorRef[F, State, SeqNr](state, timeout) { + + case (state, JournalProtocol.WriteMessagesSuccessful) => state.asLeft[SeqNr].pure[F] + + case (state, JournalProtocol.WriteMessageSuccess(persistent, _)) => + val seqNr = persistent.sequenceNr max state.maxSeqNr + val result = + if (state.writes == 1) seqNr.asRight[State] + else State(state.writes - 1, seqNr).asLeft[SeqNr] + result.pure[F] + + case (_, JournalProtocol.WriteMessageRejected(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] + + case (_, JournalProtocol.WriteMessagesFailed(error, _)) => error.raiseError[F, Either[State, SeqNr]] + + case (_, JournalProtocol.WriteMessageFailure(_, error, _)) => error.raiseError[F, Either[State, SeqNr]] + } + + for { + messages <- appendedSeqNr.modify { seqNr => + var _seqNr = seqNr + def nextSeqNr = { + _seqNr = _seqNr + 1 + _seqNr + } + val messages = events.values.toList.map { events => + val persistent = events.toList.map { event => + PersistentRepr(event, persistenceId = persistenceId, sequenceNr = nextSeqNr) + } + AtomicWrite(persistent) + } + _seqNr -> messages + } + actor <- actor + request = JournalProtocol.WriteMessages(messages, actor.ref, 0) + _ <- journaller.tell(request) + } yield actor.res + } + + } + + override def deleteTo: DeleteEventsTo[F] = new DeleteEventsTo[F] { + + override def apply(seqNr: SeqNr): F[F[Unit]] = { + + val actor = LocalActorRef[F, Unit, Unit]({}, timeout) { + case (_, DeleteMessagesSuccess(_)) => ().asRight[Unit].pure[F] + case (_, DeleteMessagesFailure(e, _)) => e.raiseError[F, Either[Unit, Unit]] + } + + for { + actor <- actor + request = JournalProtocol.DeleteMessagesTo(persistenceId, seqNr, actor.ref) + _ <- journaller.tell(request) + } yield actor.res + } + + } + } + + } + } + +} diff --git a/persistence/src/main/scala/akka/persistence/ReplayerInterop.scala b/persistence/src/main/scala/akka/persistence/ReplayerInterop.scala new file mode 100644 index 00000000..8eb70683 --- /dev/null +++ b/persistence/src/main/scala/akka/persistence/ReplayerInterop.scala @@ -0,0 +1,102 @@ +package akka.persistence + +import akka.actor.ActorSystem + +import cats.effect.{Async, Ref} +import cats.syntax.all._ + +import com.evolutiongaming.akkaeffect.ActorEffect +import com.evolutiongaming.akkaeffect.persistence.{Replayer, EventSourcedId, Event, SeqNr} +import com.evolutiongaming.catshelper.{FromFuture, ToTry} +import com.evolutiongaming.sstream.Stream +import com.evolutiongaming.sstream.FoldWhile._ + +import scala.concurrent.duration._ + +object ReplayerInterop { + + def apply[F[_]: Async: FromFuture: ToTry]( + system: ActorSystem, + timeout: FiniteDuration + ): F[Replayer.Of[F]] = + Async[F] + .delay { + Persistence(system) + } + .map { persistence => + new Replayer.Of[F] { + + override def apply[E](journalPluginId: String, eventSourcedId: EventSourcedId): F[Replayer[F, E]] = + Async[F] + .delay { + val ref = persistence.journalFor(journalPluginId) + ActorEffect.fromActor(ref) + } + .map { journaller => + new Replayer[F, E] { + + val persistenceId = eventSourcedId.value + + override def replay(fromSequenceNr: SeqNr, toSequenceNr: SeqNr, max: Long): F[Stream[F, Event[E]]] = { + + def actor(buffer: Ref[F, Vector[Event[E]]]) = + LocalActorRef[F, Unit, SeqNr]({}, timeout) { + + case (_, JournalProtocol.ReplayedMessage(persisted)) => + if (persisted.deleted) ().asLeft[SeqNr].pure[F] + else { + val payload = persisted.payload.asInstanceOf[E] + val event = Event.const(payload, persisted.sequenceNr) + buffer.update(_ :+ event).as(().asLeft[SeqNr]) + } + + case (_, JournalProtocol.RecoverySuccess(seqNr)) => seqNr.asRight[Unit].pure[F] + + case (_, JournalProtocol.ReplayMessagesFailure(error)) => error.raiseError[F, Either[Unit, SeqNr]] + } + + for { + buffer <- Ref[F].of(Vector.empty[Event[E]]) + actor <- actor(buffer) + request = JournalProtocol.ReplayMessages(fromSequenceNr, toSequenceNr, max, persistenceId, actor.ref) + _ <- journaller.tell(request) + } yield new Stream[F, Event[E]] { + + override def foldWhileM[L, R](l: L)(f: (L, Event[E]) => F[Either[L, R]]): F[Either[L, R]] = + l.asLeft[R] + .tailRecM { + + case Left(l) => + for { + events <- buffer.getAndSet(Vector.empty[Event[E]]) + done <- actor.get + result <- events.foldWhileM(l)(f) + result <- result match { + + case l: Left[L, R] => + done match { + case Some(Right(_)) => l.asRight[Either[L, R]].pure[F] // no more events + case Some(Left(er)) => er.raiseError[F, Either[Either[L, R], Either[L, R]]] // failure + case None => l.asLeft[Either[L, R]].pure[F] // expecting more events + } + + // Right(...), cos user-defined function [[f]] desided to stop consuming stream thus wrapping in Right to break tailRecM loop + case result => result.asRight[Either[L, R]].pure[F] + + } + } yield result + + case result => // cannot happened + result.asRight[Either[L, R]].pure[F] + } + + } + + } + + } + } + + } + } +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala index 4fc14071..c3faa719 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedActorOf.scala @@ -33,7 +33,6 @@ object EventSourcedActorOf { ): Actor = ActorOf[F] { actorCtx => for { eventSourced <- eventSourcedOf(actorCtx).toResource - persistentId = eventSourced.eventSourcedId recoveryStarted <- eventSourced.value persistence <- persistenceOf(eventSourced).toResource diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala new file mode 100644 index 00000000..84ffeaff --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala @@ -0,0 +1,79 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.actor.ActorSystem +import akka.persistence.{ExtendedSnapshoterInterop, ReplayerInterop, JournallerInterop, SnapshotSelectionCriteria} + +import cats.syntax.all._ +import cats.Applicative +import cats.effect.Async + +import com.evolutiongaming.catshelper.ToTry +import com.evolutiongaming.catshelper.FromFuture +import com.evolutiongaming.akkaeffect.persistence.SeqNr +import com.evolutiongaming.sstream.Stream + +import scala.concurrent.duration._ + +trait EventSourcedPersistenceOf[F[_], S, E] { + + def apply( + eventSourced: EventSourced[_] + ): F[EventSourcedPersistence[F, S, E]] + +} + +object EventSourcedPersistenceOf { + + def const[F[_]: Applicative, S, E]( + store: EventSourcedPersistence[F, S, E] + ): EventSourcedPersistenceOf[F, S, E] = + _ => store.pure[F] + + def fromAkka[F[_]: Async: ToTry: FromFuture, S, E]( + system: ActorSystem, + timeout: FiniteDuration + ): F[EventSourcedPersistenceOf[F, S, E]] = + for { + snapshotterOf <- ExtendedSnapshoterInterop[F](system, timeout) + replayerOf <- ReplayerInterop[F](system, timeout) + journallerOf <- JournallerInterop[F](system, timeout) + } yield new EventSourcedPersistenceOf[F, S, E] { + + override def apply(eventSourced: EventSourced[_]): F[EventSourcedPersistence[F, S, E]] = { + + val defaultPluginId = "" + val snapshotPluginId = eventSourced.pluginIds.snapshot.getOrElse(defaultPluginId) + val journalPluginId = eventSourced.pluginIds.journal.getOrElse(defaultPluginId) + + for { + extendedSn <- snapshotterOf[S](snapshotPluginId, eventSourced.eventSourcedId) + replayer <- replayerOf[E](journalPluginId, eventSourced.eventSourcedId) + } yield new EventSourcedPersistence[F, S, E] { + + override def recover: F[EventSourcedPersistence.Recovery[F, S, E]] = + extendedSn + .load(SnapshotSelectionCriteria(), SeqNr.Max) + .flatten + .map { snapshotOffer => + new EventSourcedPersistence.Recovery[F, S, E] { + + override def snapshot: Option[Snapshot[S]] = snapshotOffer + + override def events: Stream[F, Event[E]] = { + val fromSeqNr = snapshotOffer.map(_.metadata.seqNr).getOrElse(SeqNr.Min) + val stream = replayer.replay(fromSeqNr, SeqNr.Max, Long.MaxValue) + Stream.lift(stream).flatten + } + } + } + + override def journaller(seqNr: SeqNr): F[Journaller[F, E]] = journallerOf[E](journalPluginId, eventSourced.eventSourcedId, seqNr) + + override def snapshotter: F[Snapshotter[F, S]] = extendedSn.snapshotter.pure[F] + + } + } + + } + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/ExtendedSnapshotter.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/ExtendedSnapshotter.scala new file mode 100644 index 00000000..11944a13 --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/ExtendedSnapshotter.scala @@ -0,0 +1,21 @@ +package com.evolutiongaming.akkaeffect.persistence + +import akka.persistence.SnapshotSelectionCriteria + +trait ExtendedSnapshotter[F[_], S] extends Snapshotter[F, S] { self => + + def load(criteria: SnapshotSelectionCriteria, toSequenceNr: SeqNr): F[F[Option[Snapshot[S]]]] + + val snapshotter: Snapshotter[F, S] = self + +} + +object ExtendedSnapshotter { + + trait Of[F[_]] { + + def apply[S](snapshotPluginId: String, eventSourcedId: EventSourcedId): F[ExtendedSnapshotter[F, S]] + + } + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/JournallerOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/JournallerOf.scala new file mode 100644 index 00000000..9b6114e3 --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/JournallerOf.scala @@ -0,0 +1,7 @@ +package com.evolutiongaming.akkaeffect.persistence + +trait JournallerOf[F[_]] { + + def apply[E](journalPluginId: String, eventSourcedId: EventSourcedId, currentSeqNr: SeqNr): F[Journaller[F, E]] + +} diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Replayer.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Replayer.scala new file mode 100644 index 00000000..0d52b00d --- /dev/null +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/Replayer.scala @@ -0,0 +1,19 @@ +package com.evolutiongaming.akkaeffect.persistence + +import com.evolutiongaming.sstream.Stream + +trait Replayer[F[_], E] { + + def replay(fromSequenceNr: SeqNr, toSequenceNr: SeqNr, max: Long): F[Stream[F, Event[E]]] + +} + +object Replayer { + + trait Of[F[_]] { + + def apply[E](journalPluginId: String, eventSourcedId: EventSourcedId): F[Replayer[F, E]] + + } + +} diff --git a/persistence/src/test/scala/akka/persistence/ExtendedSnapshoterInteropTest.scala b/persistence/src/test/scala/akka/persistence/ExtendedSnapshoterInteropTest.scala new file mode 100644 index 00000000..b0ba0dfe --- /dev/null +++ b/persistence/src/test/scala/akka/persistence/ExtendedSnapshoterInteropTest.scala @@ -0,0 +1,265 @@ +package akka.persistence + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import cats.syntax.all._ +import cats.effect.IO +import cats.effect.unsafe.implicits.global + +import com.evolutiongaming.akkaeffect.persistence.{EventSourcedId, SeqNr, Event, Snapshotter} +import com.evolutiongaming.akkaeffect.testkit.TestActorSystem + +import scala.util.Random + +import scala.concurrent.Future +import scala.concurrent.duration._ +import akka.persistence.snapshot.SnapshotStore +import akka.pattern.AskTimeoutException + +class ExtendedSnapshoterInteropTest extends AnyFunSuite with Matchers { + + val emptyPluginId = "" + + test("snapshot: load, save and load again") { + + val persistenceId = EventSourcedId("#1") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](emptyPluginId, persistenceId) + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot shouldEqual none + _ <- snapshotter.save(SeqNr.Min, payload).flatten + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot.get.snapshot should equal(payload) + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: save, load, delete and load again") { + + val persistenceId = EventSourcedId("#2") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](emptyPluginId, persistenceId) + _ <- snapshotter.save(SeqNr.Min, payload).flatten + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot.get.snapshot should equal(payload) + _ <- snapshotter.delete(SeqNr.Min).flatten + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue).flatten + _ = snapshot shouldEqual none + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail load snapshot") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#3") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue) + error <- snapshot.attempt + _ = error shouldEqual FailingSnapshotter.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail save snapshot") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#4") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + saving <- snapshotter.save(SeqNr.Min, payload) + error <- saving.attempt + _ = error shouldEqual FailingSnapshotter.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail delete snapshot") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#5") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + deleting <- snapshotter.delete(SeqNr.Min) + error <- deleting.attempt + _ = error shouldEqual FailingSnapshotter.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: fail delete snapshot by criteria") { + + val pluginId = "failing-snapshot" + val persistenceId = EventSourcedId("#6") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + deleting <- snapshotter.delete(Snapshotter.Criteria()) + error <- deleting.attempt + _ = error shouldEqual FailingSnapshotter.exception.asLeft[List[Event[String]]] + } yield {} + } + + io.unsafeRunSync() + } + + test("snapshot: timeout loading snapshot") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#7") + + val io = TestActorSystem[IO]("testing", none) + .use(system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + snapshot <- snapshotter.load(SnapshotSelectionCriteria(), Long.MaxValue) + error <- snapshot.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + ) + + io.unsafeRunSync() + } + + test("snapshot: timeout saving snapshot") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#8") + val payload = Random.nextString(1024) + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + saving <- snapshotter.save(SeqNr.Min, payload) + error <- saving.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + } + + io.unsafeRunSync() + } + + test("snapshot: timeout deleting snapshot") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#9") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + deleting <- snapshotter.delete(SeqNr.Min) + error <- deleting.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + } + + io.unsafeRunSync() + } + + test("snapshot: timeout deleting snapshot by criteria") { + + val pluginId = "infinite-snapshot" + val persistenceId = EventSourcedId("#10") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + of <- ExtendedSnapshoterInterop[IO](system, 1.second) + snapshotter <- of[String](pluginId, persistenceId) + deleting <- snapshotter.delete(Snapshotter.Criteria()) + error <- deleting.attempt + } yield error match { + case Left(_: AskTimeoutException) => succeed + case Left(other) => fail(other) + case Right(_) => fail("the test should fail with AskTimeoutException but did no") + } + } + + io.unsafeRunSync() + } + +} + +object FailingSnapshotter { + + val exception = new RuntimeException("test exception") + +} + +class FailingSnapshotter extends SnapshotStore { + + override def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = + Future.failed(FailingSnapshotter.exception) + + override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit] = Future.failed(FailingSnapshotter.exception) + + override def deleteAsync(metadata: SnapshotMetadata): Future[Unit] = Future.failed(FailingSnapshotter.exception) + + override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = + Future.failed(FailingSnapshotter.exception) + +} + +class InfiniteSnapshotter extends SnapshotStore { + + override def loadAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Option[SelectedSnapshot]] = Future.never + + override def saveAsync(metadata: SnapshotMetadata, snapshot: Any): Future[Unit] = Future.never + + override def deleteAsync(metadata: SnapshotMetadata): Future[Unit] = Future.never + + override def deleteAsync(persistenceId: String, criteria: SnapshotSelectionCriteria): Future[Unit] = Future.never + +} diff --git a/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala b/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala new file mode 100644 index 00000000..a8a6bfe2 --- /dev/null +++ b/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala @@ -0,0 +1,205 @@ +package akka.persistence + +import akka.persistence.journal.AsyncWriteJournal +import cats.effect.IO +import cats.effect.unsafe.implicits.global +import cats.syntax.all._ +import com.evolutiongaming.akkaeffect.persistence.Event +import com.evolutiongaming.akkaeffect.persistence.EventSourcedId +import com.evolutiongaming.akkaeffect.persistence.Events +import com.evolutiongaming.akkaeffect.persistence.SeqNr +import com.evolutiongaming.akkaeffect.testkit.TestActorSystem +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import java.util.concurrent.TimeoutException +import scala.concurrent.Future +import scala.concurrent.duration._ +import scala.util.Try + +class JournallerAndReplayerInteropTest extends AnyFunSuite with Matchers { + + val emptyPluginId = "" + + test("journal: replay (nothing), save, replay, delete, replay") { + + val persistenceId = EventSourcedId("#11") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + replayerOf <- ReplayerInterop[IO](system, 1.second) + replayer <- replayerOf[String](emptyPluginId, persistenceId) + journallerOf <- JournallerInterop[IO](system, 1.second) + journal <- journallerOf[String](emptyPluginId, persistenceId, SeqNr.Min) + events <- replayer.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + events <- events.toList + _ = events shouldEqual List.empty[Event[String]] + seqNr <- journal.append(Events.of("first", "second")).flatten + _ = seqNr shouldEqual 2L + events <- replayer.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + events <- events.toList + _ = events shouldEqual List(Event.const("first", 1L), Event.const("second", 2L)) + _ <- journal.deleteTo(1L).flatten + events <- replayer.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + events <- events.toList + _ = events shouldEqual List(Event.const("second", 2L)) + } yield {} + } + + io.unsafeRunSync() + } + + test("journal: fail loading events") { + + val pluginId = "failing-journal" + val persistenceId = EventSourcedId("#11") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + replayerOf <- ReplayerInterop[IO](system, 1.second) + replayer <- replayerOf[String](pluginId, persistenceId) + events <- replayer.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + error <- events.toList.attempt + } yield error shouldEqual FailingJournal.exception.asLeft[List[Event[String]]] + } + + io.unsafeRunSync() + } + + test("journal: fail persisting events") { + + val pluginId = "failing-journal" + val persistenceId = EventSourcedId("#12") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + journallerOf <- JournallerInterop[IO](system, 1.second) + journal <- journallerOf[String](pluginId, persistenceId, SeqNr.Min) + seqNr <- journal.append(Events.of[String]("first", "second")) + error <- seqNr.attempt + } yield error shouldEqual FailingJournal.exception.asLeft[SeqNr] + } + + io.unsafeRunSync() + } + + test("journal: fail deleting events") { + + val pluginId = "failing-journal" + val persistenceId = EventSourcedId("#13") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + journallerOf <- JournallerInterop[IO](system, 1.second) + journal <- journallerOf[String](pluginId, persistenceId, SeqNr.Min) + deleting <- journal.deleteTo(SeqNr.Max) + error <- deleting.attempt + } yield error shouldEqual FailingJournal.exception.asLeft[Unit] + } + + io.unsafeRunSync() + } + + test("journal: timeout on loading events") { + + val pluginId = "infinite-journal" + val persistenceId = EventSourcedId("#14") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + replayerOf <- ReplayerInterop[IO](system, 1.second) + replayer <- replayerOf[String](pluginId, persistenceId) + events <- replayer.replay(SeqNr.Min, SeqNr.Max, Long.MaxValue) + error <- events.toList.attempt + } yield error match { + case Left(_: TimeoutException) => succeed + case Left(e) => fail(e) + case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") + } + } + + io.unsafeRunSync() + } + + test("journal: timeout persisting events") { + + val pluginId = "infinite-journal" + val persistenceId = EventSourcedId("#15") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + journallerOf <- JournallerInterop[IO](system, 1.second) + journal <- journallerOf[String](pluginId, persistenceId, SeqNr.Min) + seqNr <- journal.append(Events.of[String]("first", "second")) + error <- seqNr.attempt + } yield error match { + case Left(_: TimeoutException) => succeed + case Left(e) => fail(e) + case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") + } + } + + io.unsafeRunSync() + } + + test("journal: timeout deleting events") { + + val pluginId = "infinite-journal" + val persistenceId = EventSourcedId("#16") + + val io = TestActorSystem[IO]("testing", none) + .use { system => + for { + journallerOf <- JournallerInterop[IO](system, 1.second) + journal <- journallerOf[String](pluginId, persistenceId, SeqNr.Min) + deleting <- journal.deleteTo(SeqNr.Max) + error <- deleting.attempt + } yield error match { + case Left(_: TimeoutException) => succeed + case Left(e) => fail(e) + case Right(r) => fail(s"the test should fail with TimeoutException while actual result is $r") + } + } + + io.unsafeRunSync() + } +} + +object FailingJournal { + val exception = new RuntimeException("test exception") +} + +class FailingJournal extends AsyncWriteJournal { + + override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)( + recoveryCallback: PersistentRepr => Unit + ): Future[Unit] = Future.failed(FailingJournal.exception) + + override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = + Future.failed(FailingJournal.exception) + + override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = Future.failed(FailingJournal.exception) + + override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.failed(FailingJournal.exception) + +} + +class InfiniteJournal extends AsyncWriteJournal { + + override def asyncReplayMessages(persistenceId: String, fromSequenceNr: Long, toSequenceNr: Long, max: Long)( + recoveryCallback: PersistentRepr => Unit + ): Future[Unit] = Future.never + + override def asyncReadHighestSequenceNr(persistenceId: String, fromSequenceNr: Long): Future[Long] = Future.never + + override def asyncWriteMessages(messages: Seq[AtomicWrite]): Future[Seq[Try[Unit]]] = Future.never + + override def asyncDeleteMessagesTo(persistenceId: String, toSequenceNr: Long): Future[Unit] = Future.never + +} From bc9b9be0a58c07cd8092b2e901d024a7bdbd84da Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 7 Dec 2023 17:50:37 +0100 Subject: [PATCH 25/29] fix fromSeqNr --- .../akkaeffect/persistence/EventSourcedPersistenceOf.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala index 84ffeaff..b04b168b 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala @@ -60,7 +60,7 @@ object EventSourcedPersistenceOf { override def snapshot: Option[Snapshot[S]] = snapshotOffer override def events: Stream[F, Event[E]] = { - val fromSeqNr = snapshotOffer.map(_.metadata.seqNr).getOrElse(SeqNr.Min) + val fromSeqNr = snapshotOffer.map(_.metadata.seqNr + 1).getOrElse(SeqNr.Min) val stream = replayer.replay(fromSeqNr, SeqNr.Max, Long.MaxValue) Stream.lift(stream).flatten } From 68b2443b8a94197c3e7eaa3376e867c4513ed28e Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Thu, 7 Dec 2023 17:51:02 +0100 Subject: [PATCH 26/29] non-func renaming --- .../akkaeffect/persistence/EventSourcedPersistenceOf.scala | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala index b04b168b..916a00c6 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala @@ -61,8 +61,8 @@ object EventSourcedPersistenceOf { override def events: Stream[F, Event[E]] = { val fromSeqNr = snapshotOffer.map(_.metadata.seqNr + 1).getOrElse(SeqNr.Min) - val stream = replayer.replay(fromSeqNr, SeqNr.Max, Long.MaxValue) - Stream.lift(stream).flatten + val events = replayer.replay(fromSeqNr, SeqNr.Max, Long.MaxValue) + Stream.lift(events).flatten } } } From e88a92b92c21050efb52445f9523844c816b7a66 Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Sat, 9 Dec 2023 14:37:57 +0100 Subject: [PATCH 27/29] add persistenceOf test --- .../persistence/EventSourcedPersistenceOf.scala | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala index 916a00c6..96da3400 100644 --- a/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala +++ b/persistence/src/main/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOf.scala @@ -16,17 +16,13 @@ import scala.concurrent.duration._ trait EventSourcedPersistenceOf[F[_], S, E] { - def apply( - eventSourced: EventSourced[_] - ): F[EventSourcedPersistence[F, S, E]] + def apply(eventSourced: EventSourced[_]): F[EventSourcedPersistence[F, S, E]] } object EventSourcedPersistenceOf { - def const[F[_]: Applicative, S, E]( - store: EventSourcedPersistence[F, S, E] - ): EventSourcedPersistenceOf[F, S, E] = + def const[F[_]: Applicative, S, E](store: EventSourcedPersistence[F, S, E]): EventSourcedPersistenceOf[F, S, E] = _ => store.pure[F] def fromAkka[F[_]: Async: ToTry: FromFuture, S, E]( From f4ca6fe4fe291de8e8da5836a7b8e479e9f4a2ac Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Sat, 9 Dec 2023 14:39:12 +0100 Subject: [PATCH 28/29] add persistenceOf test --- .../EventSourcedPersistenceOfTest.scala | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOfTest.scala diff --git a/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOfTest.scala b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOfTest.scala new file mode 100644 index 00000000..f9414d66 --- /dev/null +++ b/persistence/src/test/scala/com/evolutiongaming/akkaeffect/persistence/EventSourcedPersistenceOfTest.scala @@ -0,0 +1,47 @@ +package com.evolutiongaming.akkaeffect.persistence + +import cats.syntax.all._ +import cats.effect.IO +import cats.effect.unsafe.implicits.global + +import com.evolutiongaming.akkaeffect.testkit.TestActorSystem + +import org.scalatest.funsuite.AnyFunSuite +import org.scalatest.matchers.should.Matchers + +import scala.concurrent.duration._ +import com.evolutiongaming.akkaeffect.persistence.SeqNr + +class EventSourcedPersistenceOfTest extends AnyFunSuite with Matchers { + + test("recover, append few events, save snapshot, append more events and recover again") { + + val io = TestActorSystem[IO]("testing", none).use { system => + val eventSourced = EventSourced[Unit](EventSourcedId("test#1"), value = {}) + + for { + persistenceOf <- EventSourcedPersistenceOf.fromAkka[IO, String, Int](system, 1.second) + persistence <- persistenceOf(eventSourced) + recover <- persistence.recover + _ = recover.snapshot shouldEqual none + events <- recover.events.toList + _ = events shouldEqual List.empty + journaller <- persistence.journaller(SeqNr.Min) + seqNr <- journaller.append(Events.of[Int](1, 2, 3, 4, 5)).flatten + _ = seqNr shouldEqual 5L + snapshotter <- persistence.snapshotter + at <- snapshotter.save(seqNr, "snapshot#1").flatten + seqNr10 <- journaller.append(Events.of[Int](6, 7, 8, 9, 10)).flatten + _ = seqNr10 shouldEqual 10L + recover <- persistence.recover + _ = recover.snapshot shouldEqual Snapshot.const("snapshot#1", Snapshot.Metadata(seqNr, at)).some + events <- recover.events.toList + _ = events shouldEqual List(6, 7, 8, 9, 10).map(n => Event.const(n, n)) + } yield {} + } + + io.unsafeRunSync() + + } + +} From 3a477bd214976208309115edb879e8ec9e65211a Mon Sep 17 00:00:00 2001 From: Denys Fakhritdinov Date: Sat, 9 Dec 2023 14:57:37 +0100 Subject: [PATCH 29/29] fix 2.12 tests --- .../akka/persistence/JournallerAndReplayerInteropTest.scala | 1 + 1 file changed, 1 insertion(+) diff --git a/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala b/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala index a8a6bfe2..435393db 100644 --- a/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala +++ b/persistence/src/test/scala/akka/persistence/JournallerAndReplayerInteropTest.scala @@ -15,6 +15,7 @@ import org.scalatest.matchers.should.Matchers import java.util.concurrent.TimeoutException import scala.concurrent.Future import scala.concurrent.duration._ +import scala.collection.immutable.Seq import scala.util.Try class JournallerAndReplayerInteropTest extends AnyFunSuite with Matchers {