Timed Actions

Timers allow for scheduling calls in the future. For example, to verify that some process have been completed or not.

Timers are persisted by the Akka Runtime and are guaranteed to run at least once.

When a timer is triggered, the scheduled call is executed. If successfully executed, the timer completes and is automatically removed. In case of a failure, the timer is rescheduled with a delay of 3 seconds. This process repeats until the call succeeds.

Timer features:

  • Timers are guaranteed to run at least once.

  • Timers can be scheduled to run at any time in the future.

  • Timers can be cancelled.

  • Timers are automatically removed once successfully completed.

  • Timers are re-scheduled in case of failures.

  • Timers failing can be limited to a maximum number of retries.

Timer limitations:

  • Maximum allowed timer command payload is 1024 bytes.

  • At most 50,000 active timers per service can be scheduled.

  • Scheduled calls identifies the component through component id and the method through the name of the method. If these changes, the scheduled method will not be invoked.

  • The type of parameter the method accepts must not change after a call has been scheduled.

Timed Action’s Effect API

The Timed Action’s Effect defines the operations that Akka should perform when an incoming command is handled by a Timed Action.

A Timed Action Effect can either:

  • return Done to confirm that the command was processed successfully

  • return an error message

See also Declarative Effects for more information.

Order Entity

To demonstrate its functionality, let’s consider an Ordering Service composed of a Key Value Entity and a Timed Action component. The Timed Action will handle the cancellation of unconfirmed orders.

Users can place an order, but the order must be confirmed within a period of time. You can think of it as an Ordering Food application where the restaurant needs to confirm if it can accept the order. If no confirmation is sent within some pre-defined period of time, the order is automatically cancelled.

Let’s have a look on how the Order Entity can be implemented.

@ComponentId("order")
public class OrderEntity extends KeyValueEntity<Order> {

  public sealed interface Result {

    public record Ok() implements Result {
      public static Ok ok = new Ok();
    }

    public record NotFound(String message) implements Result {
      public static NotFound of(String message) {
        return new NotFound(message);
      }
    }

    public record Invalid(String message) implements Result {
      public static Invalid of(String message) {
        return new Invalid(message);
      }
    }
  }

  @Override
  public Order emptyState() {
    return new Order(entityId, false, false, "", 0);
  }

  public Effect<Order> placeOrder(OrderRequest orderRequest) { (1)
    var orderId = commandContext().entityId();
    boolean placed = true;
    boolean confirmed = false;
    var newOrder = new Order(
      orderId,
      confirmed,
      placed, (2)
      orderRequest.item(),
      orderRequest.quantity());
    return effects()
      .updateState(newOrder)
      .thenReply(newOrder);
  }

  public Effect<Result> confirm() {
    var orderId = commandContext().entityId();
    if (currentState().placed()) { (3)
      return effects()
        .updateState(currentState().confirm())
        .thenReply(ok);
    } else {
      return effects().reply(Result.NotFound.of("No order found for " + orderId)); (4)
    }
  }

  public Effect<Result> cancel() {
    var orderId = commandContext().entityId();
    if (!currentState().placed()) {
      return effects().reply(Result.NotFound.of("No order found for " + orderId)); (5)
    } else if (currentState().confirmed()) {
      return effects().reply(Result.Invalid.of("Cannot cancel an already confirmed order")); (6)
    } else {
      return effects().updateState(emptyState())
        .thenReply(ok); (7)
    }
  }
}
1 The placeOrder method is responsible for the creation of an order.
2 Note that we set the placed field to true.
3 When confirming an Order, we must ensure that the Order was created before.
4 If the Order was never created, it returns NotFound.
5 Cancelling an Order that was never placed also returns NotFound.
6 While cancelling an already confirmed order returns Invalid.
7 Finally, if the Order is placed, but not confirmed, the cancel method resets the order to the emptyState.

Timed Action example

The OrderEndpoint will receive incoming messages, run some logic and then call the Order Entity as needed.

Scheduling a timer

We will first look at OrderEndpoint which acts as a controller ahead of the Order Entity. Before delegating the request to the Order Entity, the Endpoint creates a timer. The scheduling of a timer is done using the akka.javasdk.timer.TimerScheduler. To use it, you need to inject it into your component via the constructor.

@HttpEndpoint("/orders")
public class OrderEndpoint {
  private final ComponentClient componentClient;
  private final TimerScheduler timerScheduler;

  public OrderEndpoint(ComponentClient componentClient, TimerScheduler timerScheduler) { (1)
    this.componentClient = componentClient;
    this.timerScheduler = timerScheduler;
  }

  private String timerName(String orderId) {
    return "order-expiration-timer-" + orderId;
  }

  @Post
  public CompletionStage<Order> placeOrder(OrderRequest orderRequest) {

    var orderId = UUID.randomUUID().toString(); (2)

    CompletionStage<Done> timerRegistration = (3)
      timerScheduler.startSingleTimer(
        timerName(orderId), (4)
        Duration.ofSeconds(10), (5)
        componentClient.forTimedAction()
          .method(OrderTimedAction::expireOrder)
          .deferred(orderId) (6)
      );


    return
      timerRegistration.thenCompose(done ->
          componentClient.forKeyValueEntity(orderId)
            .method(OrderEntity::placeOrder)
            .invokeAsync(orderRequest)) (7)
        .thenApply(order -> order);

  }
}
1 Declaring a TimerScheduler along with the ComponentClient. Both will be provided automatically by Akka.
2 Generate a random identifier for the OrderEntity. It will be used to identify the Order, but also as a unique name for the timer.
3 Call the TimerScheduler API to register a new timer. Note that it returns CompletionStage<Done>. A successful completion means that Akka registered the timer.
4 Order id is used to generate a unique name for the timer.
5 Set the delay we want for the timer to trigger.
6 Schedule a deferred call to the Timed Action component. We will cover it in a while.
7 Finally, compose the timerRegistration CompletionStage with a call to OrderEntity to place the order.

In a nutshell, we first requested Akka to register a timer. When it completes, we know that the timer is persisted and will run at the specified time. You then proceed by placing the order.

The DeferredCall returned from the component client deferred method is a way to defer the call to Akka components. It can be persisted and executed at a later time.

The sequence of actions is important here. If we had called the entity first and then registered the timer, the Order could have been placed and the timer registration could have failed due to some network issue for example. In such a case, we would end up with an Order without an expiration timer.

But the inverse is also true. There is still the risk of registering the timer and then failing to place the Order. However, the implementation of the expire method can take that into account.

Handling the timer call

Let’s have a look at the OrderTimedAction.expireOrder method implementation.

@ComponentId("order-timed-action") (1)
public class OrderTimedAction extends TimedAction { (2)

  private final ComponentClient componentClient;

  public OrderTimedAction(ComponentClient componentClient) {
    this.componentClient = componentClient;
  }

  public Effect expireOrder(String orderId) {
    return effects().asyncDone(
        componentClient.forKeyValueEntity(orderId)
            .method(OrderEntity::cancel) (3)
            .invokeAsync()
            .thenApply(__ -> Done.done())); (4)
  }

}
1 Set @ComponentId annotation for the component.
2 Extend TimedAction class.
3 The effect will return an asynchronous call to the OrderEntity component to cancel the Order.
4 If the CompletionStage completes with a failure, we must decide if we will recover the call or not. If we recover it, the timer will be considered as completed. If we let the call fail, the timer will be re-scheduled. The OrderEntity.cancel implementation, except Ok, can return NotFound or Invalid responses. In both cases, we can consider that the timer has become obsolete and don’t need to be rescheduled, therefore we recover the call.

For all other possible errors, the call to OrderTimedAction.expireOrder will fail and the timer will be re-scheduled.

Whenever we implement a method that is called from a timer, we need to carefully handle errors inside that method. Failing to do so may cause the timer to keep re-scheduling. Therefore, we should ensure that any failure is properly handled and only propagated if the intention is to re-try the call.

Failures and retries

If a scheduled call fails it will be retried, retries are backed off exponentially, starting at 3 seconds and can reach a maximum backoff of 30 seconds if consecutive retries keep failing.

The default is to keep retrying indefinitely, but it is possible to limit the number of retries before giving up via the startSingleTimer overload parameter maxRetries.

Cancelling a timer

Next, we can have a look at confirmation and cancellation endpoint implementations.

@HttpEndpoint("/orders")
public class OrderEndpoint {
  // ...

  @Post("/{orderId}/confirm")
  public CompletionStage<HttpResponse> confirm(String orderId) {
    logger.info("Confirming order '{}'", orderId);

    return
        componentClient.forKeyValueEntity(orderId)
            .method(OrderEntity::confirm).invokeAsync() (1)
            .thenCompose(result ->
                switch (result) {
                  case OrderEntity.Result.Ok __ -> timerScheduler.cancel(timerName(orderId)) (2)
                      .thenApply(___ -> HttpResponses.ok());
                  case OrderEntity.Result.NotFound notFound ->
                      CompletableFuture.completedFuture(HttpResponses.notFound(notFound.message()));
                  case OrderEntity.Result.Invalid invalid ->
                      CompletableFuture.completedFuture(HttpResponses.badRequest(invalid.message()));
                });
  }

  @Post("/{orderId}/cancel")
  public CompletionStage<HttpResponse> cancel(String orderId) {
    logger.info("Cancelling order '{}'", orderId);

    return
      componentClient.forKeyValueEntity(orderId)
        .method(OrderEntity::cancel).invokeAsync()
        .thenCompose(req ->
          timerScheduler.cancel(timerName(orderId)))
        .thenApply(done -> HttpResponses.ok());
  }
}
1 Call the Order entity to execute confirmation.
2 If it succeeds, remove the timer.

In both methods, we call proper OrderEntity method and when it completes, we cancel the timer.

Once more, the ordering is important. It’s not a problem if the call to cancel the timer fails. As we have seen in the OrderEntity.expireOrder implementation, if the timer is triggered, but is obsolete, we will properly recover from it and signal to Akka that the timer can be removed.

We could have completely ignored the timer cancellation when handling the confirmation or the cancelling. The registered timer would then be triggered at some point later and the implementation is ready to handle gracefully this case. However, it’s always of good measure to do some housekeeping to save resources.