Search code examples
scalatimerakkascheduler

Akka (Scala) - how to properly reset timer / scheduler?


I have to fill in template to implement timeout on a shopping cart. So far I have:

import akka.actor.{Actor, ActorRef, Cancellable, Props, Timers}
import akka.event.{Logging, LoggingReceive}

import scala.concurrent.duration._
import scala.language.postfixOps

object CartActor {
  sealed trait Command
  case class AddItem(item: Any) extends Command
  case class RemoveItem(item: Any) extends Command
  case object ExpireCart extends Command
  case object StartCheckout extends Command
  case object ConfirmCheckoutCancelled extends Command
  case object ConfirmCheckoutClosed extends Command

  sealed trait Event
  case class CheckoutStarted(checkoutRef: ActorRef) extends Event

  def props = Props(new CartActor())
}

class CartActor extends Actor with Timers {
  import context._
  import CartActor._

  private val log = Logging(context.system, this)
  val cartTimerDuration: FiniteDuration = 5 seconds

  var cart: Cart = Cart.empty

  private def scheduleTimer: Cancellable =
    system.scheduler.scheduleOnce(cartTimerDuration, self, ExpireCart)

  def receive: Receive = empty

  def empty: Receive = {
    case AddItem(item) =>
      this.cart = cart.addItem(item)
      scheduleTimer
      context become nonEmpty(cart, scheduleTimer)
    case _ =>
  }

  def nonEmpty(cart: Cart, timer: Cancellable): Receive = {
    case AddItem(item) =>
      this.cart = cart.addItem(item)
      timer.cancel()
      scheduleTimer
    case RemoveItem(item) =>
      this.cart = this.cart.removeItem(item)
      if (this.cart.size != 0) {
        timer.cancel()
        scheduleTimer
      }
      else
        context become empty
    case StartCheckout =>
      context become inCheckout(this.cart)
    case ExpireCart =>
      this.cart = Cart.empty
      println("Cart expired")
      context become empty
  }

  def inCheckout(cart: Cart): Receive = {
    case ConfirmCheckoutCancelled =>
      context become nonEmpty(cart, scheduleTimer)
    case ConfirmCheckoutClosed =>
      println("Cart closed after checkout")
      context become empty
    case _ =>
  }
}

Method signatures were provided, so e.g. I can't change def nonEmpty(cart: Cart, timer: Cancellable). While adding or removing item, the timer should be reset, so user has again 5 seconds to do something. The problem is I have no idea how to do it properly - the method above clearly does not reset the timer, as it always timeouts after 5 seconds. How can I achieve that? Should I use timers instead of scheduler, e.g. timers.startSingleTimer("ExpireCart", ExpireCart, cartTimerDuration)? How should I pass this between methods? Should a timer be an attribute of the CartActor instead and I should ignore the scheduler? On a side note, when I have def nonEmpty(cart: Cart, timer: Cancellable), is the timer called anywhere implicitly, or just passed?


Solution

  • There are two problems.

    Firstly, in empty you are starting two timers:

      scheduleTimer // Creates a timer, reference lost so can't be cancelled
      context become nonEmpty(cart, scheduleTimer) // Creates a second timer
    

    More generally, you are using both mutable state and parameters to the receive method. The mutable state is not required, so delete this line:

    var cart: Cart = Cart.empty
    

    Now fix nonEmpty to pass the updated state to the new receive method rather than using this:

    def nonEmpty(cart: Cart, timer: Cancellable): Receive = {
      case AddItem(item) =>
        timer.cancel()
        context become nonEmpty(cart.addItem(item), scheduleTimer)
    
      case RemoveItem(item) =>
        timer.cancel()
        if (this.cart.size > 1) {
          context become nonEmpty(cart.remoteItem(item), scheduleTimer)
        } else {
          context become empty
        }
      case StartCheckout =>
        timer.cancel()
        context become inCheckout(cart)
      case ExpireCart =>
        timer.cancel() // Just in case :)
        println("Cart expired")
        context become empty
    }