Skip to content

Timers

A timer is a call the platform makes later on your behalf. You schedule it under a name, with a delay and a target: a handler on a timed action, which is a stateless component whose job is to coordinate other components when the time comes. "Cancel this order if it is not confirmed within an hour" is a timer that calls a timed action handler, which calls the order entity.

Timers are stored in the service's Postgres database, not in memory. A timer outlives the process that set it, a restart, and a redeployment. One instance of the service runs a sweeper, as a cluster singleton, that polls for due timers once a second and runs them. The poll interval bounds how late a timer can be, not how precisely it fires.

Delivery is at least once

A timer is removed only after its handler reports success. If the handler fails, throws, or its process cannot be reached, the timer is rescheduled with backoff: 3 seconds, doubling to a ceiling of 30 seconds, for as long as it keeps failing. Two consequences follow.

  • A handler can run more than once for one timer. Make what it does safe to repeat. Cancelling an order that is already cancelled should be a no-op, not an error.
  • "Nothing to do" is success. A timer whose work has become irrelevant — the order was confirmed after the timer was set — must return done. Returning an error reschedules it, forever. This is the sharpest edge in the timer API.

A timed action in Scala

A timed action is a class extending TimedAction whose handlers return an Effect, and a companion that registers them under wire names:

final class OrderTimers(context: TimedActionContext) extends TimedAction:

  private val client = context.componentClient

  def expireOrder(orderId: String): Effect =
    val outcome = client.forKeyValueEntity(EntityId(orderId)).call(OrderEntity.cancel).invoke()
    OrderTimers.observed.add(s"$orderId:$outcome"): Unit
    effects.done()

The handler reports success whatever the order's state was: an order that had already been confirmed has nothing to cancel, and that is not a failure. The companion:

object OrderTimers extends TimedAction.Companion[OrderTimers](ComponentId("order-timers")):
  def create(context: TimedActionContext) = new OrderTimers(context)

  val expireOrder = handler("expire-order")(_.expireOrder)

handler("expire-order")(_.expireOrder) registers a handler that takes one argument; a handler with no argument is registered the same way from a method with no parameters. The argument needs a Serializer in scope, exactly as a command's does, because it is stored with the timer.

effects.done() completes the timer. effects.error(message) fails it, so it is retried. The TimedActionContext passed to create carries the component client, timerName — which schedule fired — and previousAttempts, how many times this timer has already failed.

Scheduling and cancelling in Scala

Timers are scheduled through a TimerScheduler, which the TimerRuntime extension provides once the service has started:

val timers = TimerRuntime()

val service = Ankka.service
  .register(OrderEntity.descriptor)
  .register(OrderTimers.descriptor)
  .withExtension(timers)
  .start()

val scheduler = timers.timerScheduler

Hand timers.timerScheduler to the code that schedules timers once the service is running, for example by passing it to an endpoint from the factory given to HttpServer.of. Extensions start in the order they are added, so add the TimerRuntime before the HttpServer for the scheduler to exist when the endpoints are built. Asking for it before the timer runtime has started throws. A call is captured with deferred, which encodes the argument there and then:

scheduler.createSingleTimer("expire-o-1", 300.millis, OrderTimers.expireOrder.deferred("o-1"))
assert(scheduler.exists("expire-o-1"))
Method Meaning
createSingleTimer(name, delay, call) Run call once, after delay. Scheduling again under the same name replaces the earlier schedule.
delete(name) Cancel. Cancelling a timer that does not exist is not an error.
exists(name) Whether a timer with this name is still scheduled.

The name is the timer's identity. Scheduling twice under one name replaces the first schedule, which makes "extend the deadline" one call rather than a cancel followed by a create that could race. Build names from the thing the timer is about — expire-<orderId> — so the code that confirms the order can cancel the timer by name without having stored anything.

A timer's argument is limited to 1024 bytes. Pass an id and let the handler read what it needs, rather than passing the data itself.

A timed action in Python

A Python timed action is a class with a component_id and handlers declared with @action. It reaches other components through self.client, and reads the timer's name and attempt count from its metadata:

from ankka import Done
from ankka.effects.timed_action import TimedActionEffect
from ankka.timed_action import TimedAction, action


class Reminder(TimedAction):
    component_id = "reminder"

    @action("nudge")
    async def nudge(self, cart_id: str) -> TimedActionEffect:
        attempts = int(self.metadata.get("ankka.attempts") or "0")
        if attempts > 5:
            return self.effects.done()          # give up quietly rather than retry forever
        assert self.client is not None
        await self.client.for_key_value_entity("reminders", cart_id).call("record").invoke(reply=Done)
        return self.effects.done()

self.effects.done() completes the timer and self.effects.fail(message) fails it, so it is retried. An exception raised by the handler, or a process that cannot be reached, is retried in the same way. The metadata keys are ankka.timer, the timer's name, and ankka.attempts, the number of earlier failed attempts.

Register it with the service like any other component:

Ankka.service().register(Reminder)

Scheduling and cancelling in Python

A Python process schedules through client.timers, which forwards to the sidecar:

from datetime import timedelta

await client.timers.schedule("nudge-c1", timedelta(hours=1), "reminder", "nudge", "c1")
await client.timers.cancel("nudge-c1")

schedule(timer_id, delay, component_id, name, input) names the timed action and handler by their wire names. As in Scala, scheduling twice under one id replaces the earlier schedule, and cancelling a timer that does not exist is not an error. The timer lives in the sidecar's database, so it fires even if the process that set it has restarted in the meantime.

Timers and workflows

A workflow can wait without a timer of its own: a step that ends with a pause and a timeout transitions the workflow on its own when the time passes. Use that for deadlines that belong to one workflow instance. Use a timer when the deadline belongs to something that is not a workflow, such as an entity, or when a handler must run on a schedule set from an endpoint. See Workflows.

Testing timers

A timed action handler is ordinary code and can be called directly. In Python, TimedActionTestKit.of(Reminder).call("nudge", "c1") runs one handler with no sidecar and returns its effect. Scheduling, firing, replacement and backoff are the runtime's behaviour, and are tested through the integration test kit with the TimerRuntime extension registered — a short poll interval keeps such a test fast:

val timers = TimerRuntime(pollInterval = 200.millis)
testKit = AnkkaTestKit.start(
  Seq(OrderEntity.descriptor, OrderTimers.descriptor),
  Seq(timers)
)

See Testing for the integration test kit.