Let's start with the router actor for akka-typed.route is divided into pool router and group router.Let's first look at a demonstration of using pool-router:
val pool = Routers.pool(poolSize = 4)( // make sure the workers are restarted if they fail Behaviors.supervise(WorkerRoutee()).onFailure[Exception](SupervisorStrategy.restart)) val router = ctx.spawn(pool, "worker-pool") (0 to 10).foreach { n => router ! WorkerRoutee.DoLog(s"msg $n") }
The pool in the example above is a pool-router, meaning a routee pool with four routees.Each routee is built with WorkerRoutee(), which means there is only one type of actor in the routee pool.A pool-router is a factory method that builds (spawn) all routees directly locally (JVM).That is, all routees are children of router.
Take another look at the use of group-router:
val serviceKey = ServiceKey[Worker.Command]("log-worker")
// this would likely happen elsewhere - if we create it locally we
// can just as well use a pool
val workerRoutee = ctx.spawn(WorkerRoutee(), "worker-route")
ctx.system.receptionist ! Receptionist.Register(serviceKey, workerRoutee)
val group = Routers.group(serviceKey)
val router = ctx.spawn(group, "worker-group")
// the group router will stash messages until it sees the first listing of registered
// services from the receptionist, so it is safe to send messages right away
(0 to 10).foreach { n =>
router ! WorkerRoutee.DoLog(s"msg $n")
}
group-router and pool-router have more differences:
1. routee is built outside of a router, which uses a key to get a list of actor s with the same key through Receptionist as the routee group
2. Receptionist is global.Actors on any node can send registration messages to register on Receptionist
3. No size restriction, any actor will become routee once registered on Receptionist and will be managed by router
It should be said that group-router is the most appropriate choice if you want to assign computing tasks to the nodes in the cluster and perform the load-balance effect in parallel.However, it is up to the user to decide how many routees are needed for different operation tasks, which is not as convenient as using cluster-metrics in akka-classic to automatically increase or decrease routee instances based on node load.
Receptionist: Now that we're talking about it, let's go a little deeper about Receptionist: Receptionist is cluster-wide as mentioned above.That is, actors on any node can be registered on Receptonist to form a list of actors that exist on different nodes in the cluster.If Receptionist provides this list to a user, he or she can configure the operation task on each node to implement a distributed operation mode in a sense.Receptionist is used by registering ActorRef by sending a message to the Receptionist of this node, and then getting the latest list of actor Refs by publishing a registration change message via Receptionist:
val WorkerServiceKey = ServiceKey[Worker.TransformText]("Worker")
ctx.system.receptionist ! Receptionist.Register(WorkerServiceKey, ctx.self)
ctx.system.receptionist ! Receptionist.Subscribe(Worker.WorkerServiceKey, subscriptionAdapter)
Receptionist registration and inventory acquisition are associated with ServiceKey.The list you get should all be one type of actor, except that their addresses may be cross-node, but they can only do the same operation.From another perspective, a task is a parallel operation of actors distributed across different nodes.
As mentioned in the previous discussion, if the publish-subscribe mechanism is between two actors, the two actors also need to work within the framework of the specified information exchange protocol: we must be aware of the message types and provide the necessary message type conversion mechanisms.Here is a Receptionist registration demonstration:
object Worker {
val WorkerServiceKey = ServiceKey[Worker.TransformText]("Worker")
sealed trait Command
final case class TransformText(text: String, replyTo: ActorRef[TextTransformed]) extends Command with CborSerializable
final case class TextTransformed(text: String) extends CborSerializable
def apply(): Behavior[Command] =
Behaviors.setup { ctx =>
// each worker registers themselves with the receptionist
ctx.log.info("Registering myself with receptionist")
ctx.system.receptionist ! Receptionist.Register(WorkerServiceKey, ctx.self)
Behaviors.receiveMessage {
case TransformText(text, replyTo) =>
replyTo ! TextTransformed(text.toUpperCase)
Behaviors.same
}
}
}
Receptionist registration is straightforward: registrants do not need Receptionist to return messages, so useCtx.selfSender as message.Note that the replyTo: ActorRef[TextTransformed] for TransformText represents sender as an actor that can handle TextTransformed message types.In fact, on the sender side isCtx.askType conversion for TextTransformed is provided.
Receptionist.Subscribe Receptionist is required to return an actor list, so it is a request/response mode.The replyTo sent to Receptionist messages must be of the type that the sender can handle, as follows:
def apply(): Behavior[Event] = Behaviors.setup { ctx =>
Behaviors.withTimers { timers =>
// subscribe to available workers
val subscriptionAdapter = ctx.messageAdapter[Receptionist.Listing] {
case Worker.WorkerServiceKey.Listing(workers) =>
WorkersUpdated(workers)
}
ctx.system.receptionist ! Receptionist.Subscribe(Worker.WorkerServiceKey, subscriptionAdapter)
...
}
Ctx.messageAdapterAn from Receptionist.Listing Registration of conversion mechanism from return type to WorkersUpdated type: List type returned from Receptionist will be converted to WorkersUpdated type as follows:
...
Behaviors.receiveMessage {
case WorkersUpdated(newWorkers) =>
ctx.log.info("List of services registered with the receptionist changed: {}", newWorkers)
...
In addition, the TextTransformed conversion mentioned above is as follows:
ctx.ask[Worker.TransformText,Worker.TextTransformed](selectedWorker, Worker.TransformText(text, _)) {
case Success(transformedText) => TransformCompleted(transformedText.text, text)
case Failure(ex) => JobFailed("Processing timed out", text)
}
Ctx.askConvert TextTransformed to TransformCompleted.The complete Behavior is defined as follows:
object Frontend {
sealed trait Event
private case object Tick extends Event
private final case class WorkersUpdated(newWorkers: Set[ActorRef[Worker.TransformText]]) extends Event
private final case class TransformCompleted(originalText: String, transformedText: String) extends Event
private final case class JobFailed(why: String, text: String) extends Event
def apply(): Behavior[Event] = Behaviors.setup { ctx =>
Behaviors.withTimers { timers =>
// subscribe to available workers
val subscriptionAdapter = ctx.messageAdapter[Receptionist.Listing] {
case Worker.WorkerServiceKey.Listing(workers) =>
WorkersUpdated(workers)
}
ctx.system.receptionist ! Receptionist.Subscribe(Worker.WorkerServiceKey, subscriptionAdapter)
timers.startTimerWithFixedDelay(Tick, Tick, 2.seconds)
running(ctx, IndexedSeq.empty, jobCounter = 0)
}
}
private def running(ctx: ActorContext[Event], workers: IndexedSeq[ActorRef[Worker.TransformText]], jobCounter: Int): Behavior[Event] =
Behaviors.receiveMessage {
case WorkersUpdated(newWorkers) =>
ctx.log.info("List of services registered with the receptionist changed: {}", newWorkers)
running(ctx, newWorkers.toIndexedSeq, jobCounter)
case Tick =>
if (workers.isEmpty) {
ctx.log.warn("Got tick request but no workers available, not sending any work")
Behaviors.same
} else {
// how much time can pass before we consider a request failed
implicit val timeout: Timeout = 5.seconds
val selectedWorker = workers(jobCounter % workers.size)
ctx.log.info("Sending work for processing to {}", selectedWorker)
val text = s"hello-$jobCounter"
ctx.ask[Worker.TransformText,Worker.TextTransformed](selectedWorker, Worker.TransformText(text, _)) {
case Success(transformedText) => TransformCompleted(transformedText.text, text)
case Failure(ex) => JobFailed("Processing timed out", text)
}
running(ctx, workers, jobCounter + 1)
}
case TransformCompleted(originalText, transformedText) =>
ctx.log.info("Got completed transform of {}: {}", originalText, transformedText)
Behaviors.same
case JobFailed(why, text) =>
ctx.log.warn("Transformation of text {} failed. Because: {}", text, why)
Behaviors.same
}
Now we can demonstrate using group-router to implement some kind of cross-node distributed operation.Because group-router manages routees through Receptionist, which is cluster-wide, it means that if we build routees on each node and register with Receptionist, a cross-node routee ActorRef list will be formed.If you assign tasks to routees on this list, you should be able to achieve the effect of cluster node load balancing.Let's demonstrate this loadbalancer.The process is simple: build a workersRouter in one access point (serviceActor), then register three workerRoutees with Receptionist, break the received tasks into subtasks and send them to the workersRouter one by one.After each workerRoutee completes its task, it sends the results to an aggregator, Aggregator returns the summary results to the serverActor after checking that it has received all the results returned by the workerRoutee.First look at this serverActor:
object Service {
val routerServiceKey = ServiceKey[WorkerRoutee.Command]("workers-router")
sealed trait Command extends CborSerializable
case class ProcessText(text: String) extends Command {
require(text.nonEmpty)
}
case class WrappedResult(res: Aggregator.Response) extends Command
def serviceBehavior(workersRouter: ActorRef[WorkerRoutee.Command]): Behavior[Command] = Behaviors.setup[Command] { ctx =>
val aggregator = ctx.spawn(Aggregator(), "aggregator")
val aggregatorRef: ActorRef[Aggregator.Response] = ctx.messageAdapter(WrappedResult)
Behaviors.receiveMessage {
case ProcessText(text) =>
ctx.log.info("******************** received ProcessText command: {} ****************",text)
val words = text.split(' ').toIndexedSeq
aggregator ! Aggregator.CountText(words.size, aggregatorRef)
words.foreach { word =>
workersRouter ! WorkerRoutee.Count(word, aggregator)
}
Behaviors.same
case WrappedResult(msg) =>
msg match {
case Aggregator.Result(res) =>
ctx.log.info("************** mean length of words = {} **********", res)
}
Behaviors.same
}
}
def singletonService(ctx: ActorContext[Command], workersRouter: ActorRef[WorkerRoutee.Command]) = {
val singletonSettings = ClusterSingletonSettings(ctx.system)
.withRole("front")
SingletonActor(
Behaviors.supervise(
serviceBehavior(workersRouter)
).onFailure(
SupervisorStrategy
.restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1)
.withMaxRestarts(3)
.withResetBackoffAfter(10.seconds)
)
, "singletonActor"
).withSettings(singletonSettings)
}
def apply(): Behavior[Command] = Behaviors.setup[Command] { ctx =>
val cluster = Cluster(ctx.system)
val workersRouter = ctx.spawn(
Routers.group(routerServiceKey)
.withRoundRobinRouting(),
"workersRouter"
)
(0 until 3).foreach { n =>
val routee = ctx.spawn(WorkerRoutee(cluster.selfMember.address.toString), s"work-routee-$n")
ctx.system.receptionist ! Receptionist.register(routerServiceKey, routee)
}
val singletonActor = ClusterSingleton(ctx.system).init(singletonService(ctx, workersRouter))
Behaviors.receiveMessage {
case job@ProcessText(text) =>
singletonActor ! job
Behaviors.same
}
}
}
The overall goup-router and routee are built in apply(), and the tasks received are forwarded to singletonActor.SingletonActor is an actor with service Behavior as its core.In the servceBehavior, the tasks received are broken down and sent to the workersRouter separately.It is worth noting that the service Behavior expects to receive responses from Aggregator, and there is a request/response mode for information exchange between them, so it needs to Aggregator.Response Type conversion mechanism to WrappedResult.Also: the subtasks are sent to a workerRoutee via the workersRoute, and we need each workerRoutee to return the result of the operation to Aggregator, so the message sent to the workersRouter contains the Actor Ref of the Aggregator, such as the workersRouter!WorkerRoutee.Count(cnt, aggregatorRef).
Aggregator is a persistent Actor, as follows:
object Aggregator {
sealed trait Command
sealed trait Event extends CborSerializable
sealed trait Response
case class CountText(cnt: Int, replyTo: ActorRef[Response]) extends Command
case class MarkLength(word: String, len: Int) extends Command
case class TextCounted(cnt: Int) extends Event
case class LengthMarked(word: String, len: Int) extends Event
case class Result(meanWordLength: Double) extends Response
case class State(expectedNum: Int = 0, lens: List[Int] = Nil)
var replyTo: ActorRef[Response] = _
def commandHandler: (State,Command) => Effect[Event,State] = (st,cmd) => {
cmd match {
case CountText(cnt,ref) =>
replyTo = ref
Effect.persist(TextCounted(cnt))
case MarkLength(word,len) =>
Effect.persist(LengthMarked(word,len))
}
}
def eventHandler: (State, Event) => State = (st,ev) => {
ev match {
case TextCounted(cnt) =>
st.copy(expectedNum = cnt, lens = Nil)
case LengthMarked(word,len) =>
val state = st.copy(lens = len :: st.lens)
if (state.lens.size >= state.expectedNum) {
val meanWordLength = state.lens.sum.toDouble / state.lens.size
replyTo ! Result(meanWordLength)
State()
} else state
}
}
val takeSnapShot: (State,Event,Long) => Boolean = (st,ev,seq) => {
if (st.lens.isEmpty) {
if (ev.isInstanceOf[LengthMarked])
true
else
false
} else
false
}
def apply(): Behavior[Command] = Behaviors.supervise(
Behaviors.setup[Command] { ctx =>
EventSourcedBehavior(
persistenceId = PersistenceId("33","2333"),
emptyState = State(),
commandHandler = commandHandler,
eventHandler = eventHandler
).onPersistFailure(
SupervisorStrategy
.restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1)
.withMaxRestarts(3)
.withResetBackoffAfter(10.seconds)
).receiveSignal {
case (state, RecoveryCompleted) =>
ctx.log.info("**************Recovery Completed with state: {}***************",state)
case (state, SnapshotCompleted(meta)) =>
ctx.log.info("**************Snapshot Completed with state: {},id({},{})***************",state,meta.persistenceId, meta.sequenceNr)
case (state,RecoveryFailed(err)) =>
ctx.log.error("*************recovery failed with: {}***************",err.getMessage)
case (state,SnapshotFailed(meta,err)) =>
ctx.log.error("***************snapshoting failed with: {}*************",err.getMessage)
}.snapshotWhen(takeSnapShot)
}
).onFailure(
SupervisorStrategy
.restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1)
.withMaxRestarts(3)
.withResetBackoffAfter(10.seconds)
)
}
Notice the takeSnapShot function: This function is EventSourcedBehavior.snapshotWhen(takeSnapShot) Called.The incoming parameter is (State,Event,seqenceNr), and we need to analyze the current value of State,Event and return true to make a snapshot.
Looking at a portion of the display shows that the task has been assigned to routee s on several nodes:
20:06:59.072 [ClusterSystem-akka.actor.default-dispatcher-15] INFO com.learn.akka.WorkerRoutee$ - ************** processing [this] on akka://ClusterSystem@127.0.0.1:51182 *********** 20:06:59.072 [ClusterSystem-akka.actor.default-dispatcher-3] INFO com.learn.akka.WorkerRoutee$ - ************** processing [text] on akka://ClusterSystem@127.0.0.1:51182 *********** 20:06:59.072 [ClusterSystem-akka.actor.default-dispatcher-36] INFO com.learn.akka.WorkerRoutee$ - ************** processing [be] on akka://ClusterSystem@127.0.0.1:51182 *********** 20:06:59.236 [ClusterSystem-akka.actor.default-dispatcher-16] INFO com.learn.akka.WorkerRoutee$ - ************** processing [will] on akka://ClusterSystem@127.0.0.1:51173 *********** 20:06:59.236 [ClusterSystem-akka.actor.default-dispatcher-26] INFO com.learn.akka.WorkerRoutee$ - ************** processing [is] on akka://ClusterSystem@127.0.0.1:25251 *********** 20:06:59.236 [ClusterSystem-akka.actor.default-dispatcher-13] INFO com.learn.akka.WorkerRoutee$ - ************** processing [the] on akka://ClusterSystem@127.0.0.1:51173 *********** 20:06:59.236 [ClusterSystem-akka.actor.default-dispatcher-3] INFO com.learn.akka.WorkerRoutee$ - ************** processing [that] on akka://ClusterSystem@127.0.0.1:25251 *********** 20:06:59.236 [ClusterSystem-akka.actor.default-dispatcher-3] INFO com.learn.akka.WorkerRoutee$ - ************** processing [analyzed] on akka://ClusterSystem@127.0.0.1:25251 ***********
The source code for this example is as follows:
package com.learn.akka import akka.actor.typed._ import akka.persistence.typed._ import akka.persistence.typed.scaladsl._ import scala.concurrent.duration._ import akka.actor.typed.receptionist._ import akka.actor.typed.scaladsl.Behaviors import akka.actor.typed.scaladsl._ import akka.cluster.typed.Cluster import akka.cluster.typed.ClusterSingleton import akka.cluster.typed.ClusterSingletonSettings import akka.cluster.typed.SingletonActor import com.typesafe.config.ConfigFactory object WorkerRoutee { sealed trait Command extends CborSerializable case class Count(word: String, replyTo: ActorRef[Aggregator.Command]) extends Command def apply(nodeAddress: String): Behavior[Command] = Behaviors.setup {ctx => Behaviors.receiveMessage[Command] { case Count(word,replyTo) => ctx.log.info("************** processing [{}] on {} ***********",word,nodeAddress) replyTo ! Aggregator.MarkLength(word,word.length) Behaviors.same } } } object Aggregator { sealed trait Command sealed trait Event extends CborSerializable sealed trait Response case class CountText(cnt: Int, replyTo: ActorRef[Response]) extends Command case class MarkLength(word: String, len: Int) extends Command case class TextCounted(cnt: Int) extends Event case class LengthMarked(word: String, len: Int) extends Event case class Result(meanWordLength: Double) extends Response case class State(expectedNum: Int = 0, lens: List[Int] = Nil) var replyTo: ActorRef[Response] = _ def commandHandler: (State,Command) => Effect[Event,State] = (st,cmd) => { cmd match { case CountText(cnt,ref) => replyTo = ref Effect.persist(TextCounted(cnt)) case MarkLength(word,len) => Effect.persist(LengthMarked(word,len)) } } def eventHandler: (State, Event) => State = (st,ev) => { ev match { case TextCounted(cnt) => st.copy(expectedNum = cnt, lens = Nil) case LengthMarked(word,len) => val state = st.copy(lens = len :: st.lens) if (state.lens.size >= state.expectedNum) { val meanWordLength = state.lens.sum.toDouble / state.lens.size replyTo ! Result(meanWordLength) State() } else state } } val takeSnapShot: (State,Event,Long) => Boolean = (st,ev,seq) => { if (st.lens.isEmpty) { if (ev.isInstanceOf[LengthMarked]) true else false } else false } def apply(): Behavior[Command] = Behaviors.supervise( Behaviors.setup[Command] { ctx => EventSourcedBehavior( persistenceId = PersistenceId("33","2333"), emptyState = State(), commandHandler = commandHandler, eventHandler = eventHandler ).onPersistFailure( SupervisorStrategy .restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1) .withMaxRestarts(3) .withResetBackoffAfter(10.seconds) ).receiveSignal { case (state, RecoveryCompleted) => ctx.log.info("**************Recovery Completed with state: {}***************",state) case (state, SnapshotCompleted(meta)) => ctx.log.info("**************Snapshot Completed with state: {},id({},{})***************",state,meta.persistenceId, meta.sequenceNr) case (state,RecoveryFailed(err)) => ctx.log.error("*************recovery failed with: {}***************",err.getMessage) case (state,SnapshotFailed(meta,err)) => ctx.log.error("***************snapshoting failed with: {}*************",err.getMessage) }.snapshotWhen(takeSnapShot) } ).onFailure( SupervisorStrategy .restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1) .withMaxRestarts(3) .withResetBackoffAfter(10.seconds) ) } object Service { val routerServiceKey = ServiceKey[WorkerRoutee.Command]("workers-router") sealed trait Command extends CborSerializable case class ProcessText(text: String) extends Command { require(text.nonEmpty) } case class WrappedResult(res: Aggregator.Response) extends Command def serviceBehavior(workersRouter: ActorRef[WorkerRoutee.Command]): Behavior[Command] = Behaviors.setup[Command] { ctx => val aggregator = ctx.spawn(Aggregator(), "aggregator") val aggregatorRef: ActorRef[Aggregator.Response] = ctx.messageAdapter(WrappedResult) Behaviors.receiveMessage { case ProcessText(text) => ctx.log.info("******************** received ProcessText command: {} ****************",text) val words = text.split(' ').toIndexedSeq aggregator ! Aggregator.CountText(words.size, aggregatorRef) words.foreach { word => workersRouter ! WorkerRoutee.Count(word, aggregator) } Behaviors.same case WrappedResult(msg) => msg match { case Aggregator.Result(res) => ctx.log.info("************** mean length of words = {} **********", res) } Behaviors.same } } def singletonService(ctx: ActorContext[Command], workersRouter: ActorRef[WorkerRoutee.Command]) = { val singletonSettings = ClusterSingletonSettings(ctx.system) .withRole("front") SingletonActor( Behaviors.supervise( serviceBehavior(workersRouter) ).onFailure( SupervisorStrategy .restartWithBackoff(minBackoff = 10.seconds, maxBackoff = 60.seconds, randomFactor = 0.1) .withMaxRestarts(3) .withResetBackoffAfter(10.seconds) ) , "singletonActor" ).withSettings(singletonSettings) } def apply(): Behavior[Command] = Behaviors.setup[Command] { ctx => val cluster = Cluster(ctx.system) val workersRouter = ctx.spawn( Routers.group(routerServiceKey) .withRoundRobinRouting(), "workersRouter" ) (0 until 3).foreach { n => val routee = ctx.spawn(WorkerRoutee(cluster.selfMember.address.toString), s"work-routee-$n") ctx.system.receptionist ! Receptionist.register(routerServiceKey, routee) } val singletonActor = ClusterSingleton(ctx.system).init(singletonService(ctx, workersRouter)) Behaviors.receiveMessage { case job@ProcessText(text) => singletonActor ! job Behaviors.same } } } object LoadBalance { def main(args: Array[String]): Unit = { if (args.isEmpty) { startup("compute", 25251) startup("compute", 25252) startup("compute", 25253) startup("front", 25254) } else { require(args.size == 2, "Usage: role port") startup(args(0), args(1).toInt) } } def startup(role: String, port: Int): Unit = { // Override the configuration of the port when specified as program argument val config = ConfigFactory .parseString(s""" akka.remote.artery.canonical.port=$port akka.cluster.roles = [$role] """) .withFallback(ConfigFactory.load("cluster-persistence")) val frontEnd = ActorSystem[Service.Command](Service(), "ClusterSystem", config) if (role == "front") { println("*************** sending ProcessText command ************") frontEnd ! Service.ProcessText("this is the text that will be analyzed") } } }
cluster-persistence.conf
akka.actor.allow-java-serialization = on akka { loglevel = INFO actor { provider = cluster serialization-bindings { "com.learn.akka.CborSerializable" = jackson-cbor } } remote { artery { canonical.hostname = "127.0.0.1" canonical.port = 0 } } cluster { seed-nodes = [ "akka://ClusterSystem@127.0.0.1:25251", "akka://ClusterSystem@127.0.0.1:25252"] } # use Cassandra to store both snapshots and the events of the persistent actors persistence { journal.plugin = "akka.persistence.cassandra.journal" snapshot-store.plugin = "akka.persistence.cassandra.snapshot" } } akka.persistence.cassandra { # don't use autocreate in production journal.keyspace = "poc" journal.keyspace-autocreate = on journal.tables-autocreate = on snapshot.keyspace = "poc_snapshot" snapshot.keyspace-autocreate = on snapshot.tables-autocreate = on } datastax-java-driver { basic.contact-points = ["192.168.11.189:9042"] basic.load-balancing-policy.local-datacenter = "datacenter1" }
build.sbt
name := "learn-akka-typed" version := "0.1" scalaVersion := "2.13.1" scalacOptions in Compile ++= Seq("-deprecation", "-feature", "-unchecked", "-Xlog-reflective-calls", "-Xlint") javacOptions in Compile ++= Seq("-Xlint:unchecked", "-Xlint:deprecation") val AkkaVersion = "2.6.5" val AkkaPersistenceCassandraVersion = "1.0.0" libraryDependencies ++= Seq( "com.typesafe.akka" %% "akka-cluster-sharding-typed" % AkkaVersion, "com.typesafe.akka" %% "akka-persistence-typed" % AkkaVersion, "com.typesafe.akka" %% "akka-persistence-query" % AkkaVersion, "com.typesafe.akka" %% "akka-serialization-jackson" % AkkaVersion, "com.typesafe.akka" %% "akka-persistence-cassandra" % AkkaPersistenceCassandraVersion, "com.typesafe.akka" %% "akka-slf4j" % AkkaVersion, "ch.qos.logback" % "logback-classic" % "1.2.3" )