Akka (16): Persistent mode: Persistent FSM - state machine that can be automatically repaired

Posted by junrey on Thu, 06 Jun 2019 20:15:02 +0200

We discussed FSM, an Actor specially designed to maintain internal state, which is characterized by a special DSL that can easily perform state transition. The state transition mode of FSM is particularly suitable for business processes in reality, because its DSL can describe business functions more vividly. In order to achieve the availability of FSM, it is necessary to increase the self-healing capability for FSM. Persistent FSM is a combination of FSM and Persistent Actor, which adds the persistence of state transition events on the basis of state machine mode to realize the self-healing function of internal state. Based on the structure of FSM, Persistent FSM adds the element of domain-event, which is the goal of persistence in event-sourcing mode. PersistentFSM trait is defined as follows:

/**
 * A FSM implementation with persistent state.
 *
 * Supports the usual [[akka.actor.FSM]] functionality with additional persistence features.
 * `PersistentFSM` is identified by 'persistenceId' value.
 * State changes are persisted atomically together with domain events, which means that either both succeed or both fail,
 * i.e. a state transition event will not be stored if persistence of an event related to that change fails.
 * Persistence execution order is: persist -> wait for ack -> apply state.
 * Incoming messages are deferred until the state is applied.
 * State Data is constructed based on domain events, according to user's implementation of applyEvent function.
 *
 */
trait PersistentFSM[S <: FSMState, D, E] extends PersistentActor with PersistentFSMBase[S, D, E] with ActorLogging {...}

We see that Persistent FSM inherits Persistent Actor, which means it has the ability of event persistence and log recovery in event source mode. Another inherited type, Persistent FSMBase, is a redefinition of FSM trait. A set of DSL for persistent state transition is designed for the added persistence characteristics of state machines. The three class parameters S, D and E of Persistent FSM trait represent state type, Data and event respectively. Compared with FSM: In addition to adding event parameters, Persistent FSM is based on the FSM State type, which facilitates the serialization of states:

 /**
   * FSM state and data snapshot
   *
   * @param stateIdentifier FSM state identifier
   * @param data FSM state data
   * @param timeout FSM state timeout
   * @tparam D state data type
   */
  @InternalApi
  private[persistence] case class PersistentFSMSnapshot[D](stateIdentifier: String, data: D, timeout: Option[FiniteDuration]) extends Message

  /**
   * FSMState base trait, makes possible for simple default serialization by conversion to String
   */
  trait FSMState {
    def identifier: String
  }

Persistent FSM program structure is similar to FSM:

class PersistentFSMActor extends PersistentFSM[StateType,DataType,EventType] {

  startWith(initState,initData)  //Initial state

  when(stateA) {...}             //Handling various states
  when(stateB) {...}

  whenUnhandled {...}            //Handling Common State
  
  onTransition {...}             //State transition tracking

 }

From the point of view of this program structure, the implementation of the recovery receiveRecovery function should be implicit in the type definition:

 /**
   * After recovery events are handled as in usual FSM actor
   */
  override def receiveCommand: Receive = {
    super[PersistentFSMBase].receive
  }

  /**
   * Discover the latest recorded state
   */
  override def receiveRecover: Receive = {
    case domainEventTag(event) ⇒ startWith(stateName, applyEvent(event, stateData))
    case StateChangeEvent(stateIdentifier, timeout) ⇒ startWith(statesMap(stateIdentifier), stateData, timeout)
    case SnapshotOffer(_, PersistentFSMSnapshot(stateIdentifier, data: D, timeout)) ⇒ startWith(statesMap(stateIdentifier), data, timeout)
    case RecoveryCompleted ⇒
      initialize()
      onRecoveryCompleted()
  }

Note that initialize is out of date, so don't use it anymore. We can rewrite onRecoveryCompleted() to do some initialization. Where is the event written to the log?

  /**
   * Persist FSM State and FSM State Data
   */
  override private[akka] def applyState(nextState: State): Unit = {
    var eventsToPersist: immutable.Seq[Any] = nextState.domainEvents.toList

    //Prevent StateChangeEvent persistence when staying in the same state, except when state defines a timeout
    if (nextState.notifies || nextState.timeout.nonEmpty) {
      eventsToPersist = eventsToPersist :+ StateChangeEvent(nextState.stateName.identifier, nextState.timeout)
    }

    if (eventsToPersist.isEmpty) {
      //If there are no events to persist, just apply the state
      super.applyState(nextState)
    } else {
      //Persist the events and apply the new state after all event handlers were executed
      var nextData: D = stateData
      var handlersExecutedCounter = 0

      def applyStateOnLastHandler() = {
        handlersExecutedCounter += 1
        if (handlersExecutedCounter == eventsToPersist.size) {
          super.applyState(nextState using nextData)
          currentStateTimeout = nextState.timeout
          nextState.afterTransitionDo(stateData)
        }
      }

      persistAll[Any](eventsToPersist) {
        case domainEventTag(event) ⇒
          nextData = applyEvent(event, nextData)
          applyStateOnLastHandler()
        case StateChangeEvent(stateIdentifier, timeout) ⇒
          applyStateOnLastHandler()
      }
    }
  }

Notice that the internal function applyState override s the applyState in the parent Persistent FSMBase:

  /*
   * *******************************************
   *       Main actor receive() method
   * *******************************************
   */
  override def receive: Receive = {
    case TimeoutMarker(gen) ⇒
      if (generation == gen) {
        processMsg(StateTimeout, "state timeout")
      }
    case t @ Timer(name, msg, repeat, gen) ⇒
      if ((timers contains name) && (timers(name).generation == gen)) {
        if (timeoutFuture.isDefined) {
          timeoutFuture.get.cancel()
          timeoutFuture = None
        }
        generation += 1
        if (!repeat) {
          timers -= name
        }
        processMsg(msg, t)
      }
    case SubscribeTransitionCallBack(actorRef) ⇒
      // TODO Use context.watch(actor) and receive Terminated(actor) to clean up list
      listeners.add(actorRef)
      // send current state back as reference point
      actorRef ! CurrentState(self, currentState.stateName, currentState.timeout)
    case Listen(actorRef) ⇒
      // TODO Use context.watch(actor) and receive Terminated(actor) to clean up list
      listeners.add(actorRef)
      // send current state back as reference point
      actorRef ! CurrentState(self, currentState.stateName, currentState.timeout)
    case UnsubscribeTransitionCallBack(actorRef) ⇒
      listeners.remove(actorRef)
    case Deafen(actorRef) ⇒
      listeners.remove(actorRef)
    case value ⇒
      if (timeoutFuture.isDefined) {
        timeoutFuture.get.cancel()
        timeoutFuture = None
      }
      generation += 1
      processMsg(value, sender())
  }

  private def processMsg(value: Any, source: AnyRef): Unit = {
    val event = Event(value, currentState.stateData)
    processEvent(event, source)
  }

  private[akka] def processEvent(event: Event, source: AnyRef): Unit = {
    val stateFunc = stateFunctions(currentState.stateName)
    val nextState = if (stateFunc isDefinedAt event) {
      stateFunc(event)
    } else {
      // handleEventDefault ensures that this is always defined
      handleEvent(event)
    }
    applyState(nextState)
  }

  private[akka] def applyState(nextState: State): Unit = {
    nextState.stopReason match {
      case None ⇒ makeTransition(nextState)
      case _ ⇒
        nextState.replies.reverse foreach { r ⇒ sender() ! r }
        terminate(nextState)
        context.stop(self)
    }
  }

The abstract function receiveCommand in Persistent FSM trait directly calls receive in Persistent FSM MBase when it is implemented:

 /**
   * After recovery events are handled as in usual FSM actor
   */
  override def receiveCommand: Receive = {
    super[PersistentFSMBase].receive
  }

PersistentFSM also needs to implement the abstract function applyEvent:

  /**
   * Override this handler to define the action on Domain Event
   *
   * @param domainEvent domain event to apply
   * @param currentData state data of the previous state
   * @return updated state data
   */
  def applyEvent(domainEvent: E, currentData: D): D

The main function of this function is to convert the current state data for the event. Another abstract function that needs to be implemented is domainEventClassTag. This is an example of ClassTag[E], which is used to solve the pattern matching problem of generic E (caused by type-erasure wiped by scala language type):

  /**
   * Enables to pass a ClassTag of a domain event base type from the implementing class
   *
   * @return [[scala.reflect.ClassTag]] of domain event base type
   */
  implicit def domainEventClassTag: ClassTag[E]

  /**
   * Domain event's [[scala.reflect.ClassTag]]
   * Used for identifying domain events during recovery
   */
  val domainEventTag = domainEventClassTag
...
  /**
   * Discover the latest recorded state
   */
  override def receiveRecover: Receive = {
    case domainEventTag(event) ⇒ startWith(stateName, applyEvent(event, stateData))
...
   persistAll[Any](eventsToPersist) {
        case domainEventTag(event) ⇒
          nextData = applyEvent(event, nextData)
          applyStateOnLastHandler()
        case StateChangeEvent(stateIdentifier, timeout) ⇒
          applyStateOnLastHandler()
      }

The example in the official document akka-persistent FSM is quite representative, and I will demonstrate it based on this example. This is an example of an e-commerce shopping cart. The greatest advantage of using Persistent FSM is that it can ensure the consistency of shopping cart content in any case. And it can automatically save all the history purchase process of e-commerce users for the convenience of future big data analysis - this is a trend, and even the shopping cart temporarily abandoned in the middle can be automatically restored at the next landing. Well, let's look at this example first: data structure:

import akka.persistence.fsm.PersistentFSM._

object WebShopping {
  sealed trait UserState extends FSMState  //State type
  case object LookingAround extends UserState {  //Browse status, rotatable Shopping state
    override def identifier: String = "Looking Around"
  }
  case object Shopping extends UserState {  //Picking status, can be transferred to Paid State or timeout Inactive
    override def identifier: String = "Shopping"
  }
  case object Inactive extends UserState {  //Stagnation, reversible Shopping state
    override def identifier: String = "Inactive"
  }
  case object Paid extends UserState {   //Checkout completed shopping, can only query the results of shopping, or withdraw
    override def identifier: String = "Paid"
  }

  case class Item(id: String, name: String, price: Float)
  //state data
  sealed trait ShoppingCart {  //true functional structure
    def addItem(item: Item): ShoppingCart
    def removeItem(id: String): ShoppingCart
    def empty(): ShoppingCart
  }
  case class LoadedCart(items: Seq[Item]) extends ShoppingCart {
    override def addItem(item: Item): ShoppingCart = LoadedCart(items :+ item)
    override def removeItem(id: String): ShoppingCart = {
      val newItems = items.filter {item => item.id != id}
      if (newItems.length > 0)
        LoadedCart(newItems)
      else
        EmptyCart
    }
    override def empty() = EmptyCart
  }
  case object EmptyCart extends ShoppingCart {
    override def addItem(item: Item) = LoadedCart(item :: Nil)
    override def empty() = this
    override def removeItem(id: String): ShoppingCart = this
  }

}

UserState is the current state of the FSM. State represents the process of FSM, and each state runs its own business process:

  when(LookingAround) {...}             //Handling various states
  when(Shopping) {...}
  when(Inactive) {...}
  when(Paid) {...}
...

Shopping Cart represents the current status of the FSM data. Each state may have different data. Note that Shopping Cart is a typical functional data structure: an immutable structure, and any update operation returns a new structure. StateData Shopping Cart is updated in the abstract function applyEvent. Look at applyEvent's functional style:

  /**
   * Override this handler to define the action on Domain Event
   *
   * @param domainEvent domain event to apply
   * @param currentData state data of the previous state
   * @return updated state data
   */
  def applyEvent(domainEvent: E, currentData: D): D

The user is required to provide the implementation of this function: generate new state data according to the event and current state data. The applyEvent function is called as follows:

 override def receiveRecover: Receive = {
    case domainEventTag(event) ⇒ startWith(stateName, applyEvent(event, stateData))
    case StateChangeEvent(stateIdentifier, timeout) ⇒ startWith(statesMap(stateIdentifier), stateData, timeout)
    case SnapshotOffer(_, PersistentFSMSnapshot(stateIdentifier, data: D, timeout)) ⇒ startWith(statesMap(stateIdentifier), data, timeout)
    case RecoveryCompleted ⇒
      initialize()
      onRecoveryCompleted()
  }
...
 /**
   * Persist FSM State and FSM State Data
   */
  override private[akka] def applyState(nextState: State): Unit = {
    var eventsToPersist: immutable.Seq[Any] = nextState.domainEvents.toList

    //Prevent StateChangeEvent persistence when staying in the same state, except when state defines a timeout
    if (nextState.notifies || nextState.timeout.nonEmpty) {
      eventsToPersist = eventsToPersist :+ StateChangeEvent(nextState.stateName.identifier, nextState.timeout)
    }

    if (eventsToPersist.isEmpty) {
      //If there are no events to persist, just apply the state
      super.applyState(nextState)
    } else {
      //Persist the events and apply the new state after all event handlers were executed
      var nextData: D = stateData
      var handlersExecutedCounter = 0

      def applyStateOnLastHandler() = {
        handlersExecutedCounter += 1
        if (handlersExecutedCounter == eventsToPersist.size) {
          super.applyState(nextState using nextData)
          currentStateTimeout = nextState.timeout
          nextState.afterTransitionDo(stateData)
        }
      }

      persistAll[Any](eventsToPersist) {
        case domainEventTag(event) ⇒
          nextData = applyEvent(event, nextData)
          applyStateOnLastHandler()
        case StateChangeEvent(stateIdentifier, timeout) ⇒
          applyStateOnLastHandler()
      }
    }
  }

State transition is achieved by stay, goto,stop:

 /**
   * Produce transition to other state.
   * Return this from a state function in order to effect the transition.
   *
   * This method always triggers transition events, even for `A -> A` transitions.
   * If you want to stay in the same state without triggering an state transition event use [[#stay]] instead.
   *
   * @param nextStateName state designator for the next state
   * @return state transition descriptor
   */
  final def goto(nextStateName: S): State = PersistentFSM.State(nextStateName, currentState.stateData)()

  /**
   * Produce "empty" transition descriptor.
   * Return this from a state function when no state change is to be effected.
   *
   * No transition event will be triggered by [[#stay]].
   * If you want to trigger an event like `S -> S` for `onTransition` to handle use `goto` instead.
   *
   * @return descriptor for staying in current state
   */
  final def stay(): State = goto(currentState.stateName).withNotification(false) // cannot directly use currentState because of the timeout field

  /**
   * Produce change descriptor to stop this FSM actor with reason "Normal".
   */
  final def stop(): State = stop(Normal)

State data conversion is implemented by applying:

 /**
     * Specify domain events to be applied when transitioning to the new state.
     */
    @varargs def applying(events: E*): State[S, D, E] = {
      copy(domainEvents = domainEvents ++ events)
    }

    /**
     * Register a handler to be triggered after the state has been persisted successfully
     */
    def andThen(handler: D ⇒ Unit): State[S, D, E] = {
      copy(afterTransitionDo = handler)
    }

applying operates on State[S,D,E]. State[S,D,E] is defined as follows:

 /**
   * This captures all of the managed state of the [[akka.actor.FSM]]: the state
   * name, the state data, possibly custom timeout, stop reason, replies
   * accumulated while processing the last message, possibly domain event and handler
   * to be executed after FSM moves to the new state (also triggered when staying in the same state)
   */
  final case class State[S, D, E](
    stateName:         S,
    stateData:         D,
    timeout:           Option[FiniteDuration] = None,
    stopReason:        Option[Reason]         = None,
    replies:           List[Any]              = Nil,
    domainEvents:      Seq[E]                 = Nil,
    afterTransitionDo: D ⇒ Unit               = { _: D ⇒ })(private[akka] val notifies: Boolean = true) {

    /**
     * Copy object and update values if needed.
     */
    @InternalApi
    private[akka] def copy(stateName: S = stateName, stateData: D = stateData, timeout: Option[FiniteDuration] = timeout, stopReason: Option[Reason] = stopReason, replies: List[Any] = replies, notifies: Boolean = notifies, domainEvents: Seq[E] = domainEvents, afterTransitionDo: D ⇒ Unit = afterTransitionDo): State[S, D, E] = {
      State(stateName, stateData, timeout, stopReason, replies, domainEvents, afterTransitionDo)(notifies)
    }

applying actually stores events in a list of domainEvents and then applies them when the applyState function is called:

 /**
   * Persist FSM State and FSM State Data
   */
  override private[akka] def applyState(nextState: State): Unit = {
    var eventsToPersist: immutable.Seq[Any] = nextState.domainEvents.toList
...

Persistent FSM inherits the Persistent Actor event-sourcing pattern. The following are the type definitions of command and event:

  sealed trait Command
  case class AddItem(item: Item) extends Command
  case class RemoveItem(id: String) extends Command
  case object Buy extends Command
  case object Leave extends Command
  case object GetCart extends Command

  sealed trait DomainEvent
  case class ItemAdded(item: Item) extends DomainEvent
  case class ItemRemoved(id: String) extends DomainEvent
  case object OrderClosed extends DomainEvent

We know that DomainEvent will be written to the log, and its relationship with Command is that DomainEvent will be generated when some Command is computed, and then these generated DomainEvents will be written to the log.

We started designing this Persistent FSM:

class WebShopping(webUserId: String) extends PersistentFSM[UserState,ShoppingCart,DomainEvent] {
  override def persistenceId: String = webUserId
  override def domainEventClassTag: ClassTag[DomainEvent] = classTag[DomainEvent]

  override def applyEvent(domainEvent: DomainEvent, currentCart: ShoppingCart): ShoppingCart =
     domainEvent match {
       case ItemAdded(item) => currentCart.addItem(item)
       case ItemRemoved(id) => currentCart.removeItem(id)
       case OrderClosed => currentCart.empty()
     }
}

We first implement abstract functions in trait. The persistenceId represents the current shopper's userid. In this way, we can write the user's shopping process to the log. Imagine what this means: We use a separate actor to handle a user's shopping process. Actor requires very little resources, but its computing power is efficient and powerful. If there is enough memory on a server, it can easily load hundreds of thousands or even millions of Actor instances. If we use akka-cluster again, we unconsciously have realized an e-commerce website that can accommodate millions of users.

Okay, now let's look at the complete business process of Persistent FSM:

class WebShopping(webUserId: String) extends PersistentFSM[UserState,ShoppingCart,DomainEvent] {
  override def persistenceId: String = webUserId
  override def domainEventClassTag: ClassTag[DomainEvent] = classTag[DomainEvent]

  override def applyEvent(event: DomainEvent, currentCart: ShoppingCart): ShoppingCart =
     event match {
       case ItemAdded(item) => currentCart.addItem(item)
       case ItemRemoved(id) => currentCart.removeItem(id)
       case OrderClosed => currentCart.empty()  //Clear up after successful purchase ShoppingCart
     }

  startWith(LookingAround,EmptyCart)  //Initial shopping status

  when(LookingAround) {   //When browsing, you can add a shopping cart to go to Shopping state
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"LookingAround-Adding Item: $item",currentCart))
      goto(Shopping) applying ItemAdded(item) forMax(1 second)
    case Event(GetCart,currentCart) =>
      stay replying currentCart
  }

  when(Shopping) {
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Adding Item: $item",currentCart))
      stay applying ItemAdded(item) forMax (1 second) andThen {
        case cart @ _ =>
          context.system.eventStream.publish(CurrentCart(s"Shopping-after adding Item: $item",cart))
      }
    case Event(RemoveItem(id),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Removing Item: $id",currentCart))
      stay applying ItemRemoved(id) forMax (1 second) andThen {
        case cart @ _ =>
          context.system.eventStream.publish(CurrentCart(s"Shopping-after removing Item: $id",cart))
     }
    case Event(Buy,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Buying",currentCart))
      goto(Paid) applying OrderClosed forMax (1 second) andThen {
        case cart @ _ => saveStateSnapshot()
          context.system.eventStream.publish(CurrentCart(s"Shopping-after paid",cart))
      }
      
    case Event(Leave,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Leaving",currentCart))
      stop()
    case Event(StateTimeout,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Timeout",currentCart))
      goto(Inactive) forMax(1 second)
    case Event(GetCart,currentCart) =>
      stay replying currentCart
  }

  when(Inactive) {
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Inactive-Adding Item: $item",currentCart))
      goto(Shopping) applying ItemAdded(item) forMax(1 second)
    case Event(StateTimeout,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Inactive-Timeout",currentCart))
      stop()
  }

  when(Paid) {
    case Event(Leave,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Paid-Leaving",currentCart))
      stop()
    case Event(GetCart,currentCart) =>
      stay replying currentCart
  }
}

We see that the specific technical features and details of DSL, Persistent Actor and FSM through FSM are hidden. What we present to programmers is a description of business processes, which can make the functions represented by the whole code closer to the real application and easier to understand.

Following are examples of data snapshots, log maintenance, and process tracking.

  whenUnhandled {
    case Event(SaveSnapshotSuccess(metadata),currentCart) =>
      context.system.eventStream.publish(CurrentCart("Successfully saved snapshot",currentCart))
      //If you don't need to save the historical shopping process, you can clean up the logs and old snapshots
      deleteSnapshots(SnapshotSelectionCriteria(maxSequenceNr = metadata.sequenceNr - 1))
      deleteMessages(metadata.sequenceNr)
      stay()
    case Event(SaveSnapshotFailure(metadata, reason),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Fail to save snapshot for $reason",currentCart))
      stay()
    case Event(DeleteMessagesSuccess(toSeq),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Succefully deleted journal upto: $toSeq",currentCart))
      stay()
    case Event(DeleteMessagesFailure(cause,toSeq),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Failed to delete journal upto: $toSeq because: $cause",currentCart))
      stay()
    case Event(DeleteSnapshotsSuccess(crit),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Successfully deleted snapshots for $crit",currentCart))
      stay()
    case Event(DeleteSnapshotsFailure(crit,cause),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Failed to delete snapshots $crit because: $cause",currentCart))
      stay()
  }

  onTransition {
    case LookingAround -> Shopping =>
       context.system.eventStream.publish(CurrentCart("LookingAround -> Shopping",stateData))
    case Shopping -> Inactive =>
      context.system.eventStream.publish(CurrentCart("Shopping -> Inactive",stateData))
    case Shopping -> Paid =>
      context.system.eventStream.publish(CurrentCart("Shopping -> Paid",stateData))
    case Inactive -> Shopping =>
      context.system.eventStream.publish(CurrentCart("Inactive -> Shopping",stateData))
  }
  override def onRecoveryCompleted(): Unit =
    context.system.eventStream.publish(CurrentCart("OnRecoveryCompleted",stateData))

  override def onPersistFailure(cause: Throwable, event: Any, seqNr: Long): Unit =
    context.system.eventStream.publish(CurrentCart(s"onPersistFailure ${cause.getMessage}",stateData))

  override def onPersistRejected(cause: Throwable, event: Any, seqNr: Long): Unit =
    context.system.eventStream.publish(CurrentCart(s"onPersistRejected ${cause.getMessage}",stateData))

  override def onRecoveryFailure(cause: Throwable, event: Option[Any]): Unit =
    context.system.eventStream.publish(CurrentCart(s"onRecoveryFailure ${cause.getMessage}",stateData))

The following is the design code of the process tracker:

package persistentfsm.tracker
import akka.actor._
import persistentfsm.cart.WebShopping
object EventTracker {
  def props = Props(new EventTracker)
}
class EventTracker extends Actor {
  override def preStart(): Unit = {
    context.system.eventStream.subscribe(self,classOf[WebShopping.CurrentCart])
    super.preStart()
  }

  override def postStop(): Unit = {
    context.system.eventStream.unsubscribe(self)
    super.postStop()
  }

  override def receive: Receive = {
    case WebShopping.CurrentCart(loc,cart) =>
      println(loc)
      cart match {
        case WebShopping.EmptyCart => println("empty cart!")
        case WebShopping.LoadedCart(items) => println(s"Current content in cart: $items")
      }
  }

}

Test the run with the following code:

package persistentfsm.demo
import persistentfsm.cart._
import persistentfsm.tracker._
import akka.actor._
import WebShopping._

object PersistentFSMDemo extends App {
   val pfSystem = ActorSystem("persistentfsm-system")
   val trackerActor = pfSystem.actorOf(EventTracker.props,"tracker")
   val cart123 = pfSystem.actorOf(WebShopping.props("123"))

   cart123 ! GetCart
   cart123 ! AddItem(Item("001","Cigar",12.50))
   cart123 ! AddItem(Item("002","Wine",18.30))
   cart123 ! AddItem(Item("003","Coffee",5.50))
   cart123 ! GetCart
   cart123 ! RemoveItem("001")
   cart123 ! Buy
   cart123 ! GetCart
   cart123 ! AddItem(Item("004","Bread",3.25))
   cart123 ! AddItem(Item("005","Cake",5.25))



   scala.io.StdIn.readLine()

   pfSystem.terminate()

}

Repeated operations can be concluded that the goods purchased after checkout can be restored. If abnormal exit occurs midway, the goods already purchased in the shopping cart will remain unchanged.

The following is the complete source code for this demonstration:

build.sbt

name := "persistent-fsm"

version := "1.0"

scalaVersion := "2.11.9"

sbtVersion := "0.13.5"

libraryDependencies ++= Seq(
  "com.typesafe.akka"           %% "akka-actor"       % "2.5.3",
  "com.typesafe.akka"           %% "akka-persistence" % "2.5.3",
  "ch.qos.logback" % "logback-classic" % "1.1.7",
  "com.typesafe.akka" %% "akka-persistence-cassandra" % "0.54",
  "com.typesafe.akka" %% "akka-persistence-cassandra-launcher" % "0.54" % Test
)

application.conf

akka {
  persistence {
    journal.plugin = "cassandra-journal"
    snapshot-store.plugin = "cassandra-snapshot-store"
    fsm {
      snapshot-after = 10
    }
  }
}
akka.actor.warn-about-java-serializer-usage = off

WebShopping.scala

package persistentfsm.cart
import WebShopping._
import akka.persistence.fsm._
import akka.persistence.fsm.PersistentFSM._
import akka.persistence._
import akka.actor._
import scala.concurrent.duration._
import scala.reflect._

object WebShopping {
  sealed trait UserState extends FSMState  //State type
  case object LookingAround extends UserState {  //Browse status, rotatable Shopping state
    override def identifier: String = "Looking Around"
  }
  case object Shopping extends UserState {  //Picking status, can be transferred to Paid State or timeout Inactive
    override def identifier: String = "Shopping"
  }
  case object Inactive extends UserState {  //Stagnation, reversible Shopping state
    override def identifier: String = "Inactive"
  }
  case object Paid extends UserState {   //Checkout completed shopping, can only query the results of shopping, or withdraw
    override def identifier: String = "Paid"
  }

  case class Item(id: String, name: String, price: Double)
  //state data
  sealed trait ShoppingCart {  //true functional structure
    def addItem(item: Item): ShoppingCart
    def removeItem(id: String): ShoppingCart
    def empty(): ShoppingCart
  }
  case class LoadedCart(items: Seq[Item]) extends ShoppingCart {
    override def addItem(item: Item): ShoppingCart = LoadedCart(items :+ item)
    override def removeItem(id: String): ShoppingCart = {
      val newItems = items.filter {item => item.id != id}
      if (newItems.length > 0)
        LoadedCart(newItems)
      else
        EmptyCart
    }
    override def empty() = EmptyCart
  }
  case object EmptyCart extends ShoppingCart {
    override def addItem(item: Item) = LoadedCart(item :: Nil)
    override def empty() = this
    override def removeItem(id: String): ShoppingCart = this
  }

  sealed trait Command
  case class AddItem(item: Item) extends Command
  case class RemoveItem(id: String) extends Command
  case object Buy extends Command
  case object Leave extends Command
  case object GetCart extends Command

  sealed trait DomainEvent
  case class ItemAdded(item: Item) extends DomainEvent
  case class ItemRemoved(id: String) extends DomainEvent
  case object OrderClosed extends DomainEvent
//logging message type
  case class CurrentCart(location: String, cart: ShoppingCart)

  def props(uid: String) = Props(new WebShopping(uid))

}
class WebShopping(webUserId: String) extends PersistentFSM[UserState,ShoppingCart,DomainEvent] {
  override def persistenceId: String = webUserId
  override def domainEventClassTag: ClassTag[DomainEvent] = classTag[DomainEvent]

  override def applyEvent(event: DomainEvent, currentCart: ShoppingCart): ShoppingCart =
     event match {
       case ItemAdded(item) => currentCart.addItem(item)
       case ItemRemoved(id) => currentCart.removeItem(id)
       case OrderClosed => currentCart.empty()  //Clear up after successful purchase ShoppingCart
     }

  startWith(LookingAround,EmptyCart)  //Initial shopping status

  when(LookingAround) {   //When browsing, you can add a shopping cart to go to Shopping state
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"LookingAround-Adding Item: $item",currentCart))
      goto(Shopping) applying ItemAdded(item) forMax(1 second)
    case Event(GetCart,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"LookingAround-Showing",currentCart))
      stay replying currentCart
  }

  when(Shopping) {
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Adding Item: $item",currentCart))
      stay applying ItemAdded(item) forMax (1 second) andThen {
        case cart @ _ =>
          context.system.eventStream.publish(CurrentCart(s"Shopping-after adding Item: $item",cart))
      }
    case Event(RemoveItem(id),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Removing Item: $id",currentCart))
      stay applying ItemRemoved(id) forMax (1 second) andThen {
        case cart @ _ =>
          context.system.eventStream.publish(CurrentCart(s"Shopping-after removing Item: $id",cart))
     }
    case Event(Buy,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Buying",currentCart))
      goto(Paid) applying OrderClosed forMax (1 second) andThen {
        case cart @ _ => saveStateSnapshot()
          context.system.eventStream.publish(CurrentCart(s"Shopping-after paid",cart))
      }

    case Event(Leave,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Leaving",currentCart))
      stop()
    case Event(StateTimeout,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Shopping-Timeout",currentCart))
      goto(Inactive) forMax(1 second)
    case Event(GetCart,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"LookingAround-Showing",currentCart))
      stay replying currentCart
  }

  when(Inactive) {
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Inactive-Adding Item: $item",currentCart))
      goto(Shopping) applying ItemAdded(item) forMax(1 second)
    case Event(StateTimeout,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Inactive-Timeout",currentCart))
      stop()
  }

  when(Paid) {
    case Event(Leave,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Paid-Leaving",currentCart))
      stop()
    case Event(GetCart,currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Paid-Showing",currentCart))
      stay replying currentCart
    case Event(AddItem(item),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Paid-Adding Item: $item",currentCart))
      goto(Shopping) applying ItemAdded(item) forMax(1 second)
  }

  whenUnhandled {
    case Event(SaveSnapshotSuccess(metadata),currentCart) =>
      context.system.eventStream.publish(CurrentCart("Successfully saved snapshot",currentCart))
      //If you don't need to save the historical shopping process, you can clean up the logs and old snapshots
      deleteSnapshots(SnapshotSelectionCriteria(maxSequenceNr = metadata.sequenceNr - 1))
      deleteMessages(metadata.sequenceNr)
      stay()
    case Event(SaveSnapshotFailure(metadata, reason),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Fail to save snapshot for $reason",currentCart))
      stay()
    case Event(DeleteMessagesSuccess(toSeq),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Succefully deleted journal upto: $toSeq",currentCart))
      stay()
    case Event(DeleteMessagesFailure(cause,toSeq),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Failed to delete journal upto: $toSeq because: $cause",currentCart))
      stay()
    case Event(DeleteSnapshotsSuccess(crit),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Successfully deleted snapshots for $crit",currentCart))
      stay()
    case Event(DeleteSnapshotsFailure(crit,cause),currentCart) =>
      context.system.eventStream.publish(CurrentCart(s"Failed to delete snapshots $crit because: $cause",currentCart))
      stay()
    case _ => goto(LookingAround)
  }

  onTransition {
    case LookingAround -> Shopping =>
       context.system.eventStream.publish(CurrentCart("State changed: LookingAround -> Shopping",stateData))
    case Shopping -> Inactive =>
      context.system.eventStream.publish(CurrentCart("State changed: Shopping -> Inactive",stateData))
    case Shopping -> Paid =>
      context.system.eventStream.publish(CurrentCart("State changed: Shopping -> Paid",stateData))
    case Inactive -> Shopping =>
      context.system.eventStream.publish(CurrentCart("State changed: Inactive -> Shopping",stateData))
    case Paid -> LookingAround =>
      context.system.eventStream.publish(CurrentCart("State changed: Paid -> LookingAround",stateData))
  }
  override def onRecoveryCompleted(): Unit = {
    context.system.eventStream.publish(CurrentCart("OnRecoveryCompleted", stateData))
  }

  override def onPersistFailure(cause: Throwable, event: Any, seqNr: Long): Unit =
    context.system.eventStream.publish(CurrentCart(s"onPersistFailure ${cause.getMessage}",stateData))

  override def onPersistRejected(cause: Throwable, event: Any, seqNr: Long): Unit =
    context.system.eventStream.publish(CurrentCart(s"onPersistRejected ${cause.getMessage}",stateData))

  override def onRecoveryFailure(cause: Throwable, event: Option[Any]): Unit =
    context.system.eventStream.publish(CurrentCart(s"onRecoveryFailure ${cause.getMessage}",stateData))


}

EventTracker.scala

package persistentfsm.tracker
import akka.actor._
import persistentfsm.cart.WebShopping
object EventTracker {
  def props = Props(new EventTracker)
}
class EventTracker extends Actor {
  override def preStart(): Unit = {
    context.system.eventStream.subscribe(self,classOf[WebShopping.CurrentCart])
    super.preStart()
  }

  override def postStop(): Unit = {
    context.system.eventStream.unsubscribe(self)
    super.postStop()
  }

  override def receive: Receive = {
    case WebShopping.CurrentCart(loc,cart) =>
      println(loc)
      cart match {
        case WebShopping.EmptyCart => println("empty cart!")
        case WebShopping.LoadedCart(items) => println(s"Current content in cart: $items")
      }
  }

}

PersistentFSMDemo.scala

package persistentfsm.demo
import persistentfsm.cart._
import persistentfsm.tracker._
import akka.actor._
import WebShopping._

object PersistentFSMDemo extends App {
   val pfSystem = ActorSystem("persistentfsm-system")
   val trackerActor = pfSystem.actorOf(EventTracker.props,"tracker")
   val cart123 = pfSystem.actorOf(WebShopping.props("123"))

   cart123 ! GetCart
   cart123 ! AddItem(Item("001","Cigar",12.50))
   cart123 ! AddItem(Item("002","Wine",18.30))
   cart123 ! AddItem(Item("003","Coffee",5.50))
   cart123 ! GetCart
   cart123 ! RemoveItem("001")
   cart123 ! Buy
   cart123 ! GetCart
   cart123 ! AddItem(Item("004","Bread",3.25))
   cart123 ! AddItem(Item("005","Cake",5.25))



   scala.io.StdIn.readLine()

   pfSystem.terminate()

}

Topics: Scala snapshot Big Data Java