Timers

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

Timers are persisted in the Kalix 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.

You can schedule a timer for any service method in Kalix, but you can only create a timer from within an Action or Workflow by passing a DeferredCall.

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:

  • Timers can only be scheduled from within an Action or Workflow.

  • Timers can only call other components (e.g., Actions, Entities, Workflows), therefore external service calls must be wrapped by an Action in the deployed service.

To demonstrate its functionality, let’s consider an Ordering Service composed of a Value Entity and an Action. The Action will work as a Controller receiving calls and delegating to the Order Entity.

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.

Order Entity

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

src/main/java/com/example/domain/OrderEntity.java
@Id("id")
@TypeId("order")
@RequestMapping("/order/{id}")
public class OrderEntity extends ValueEntity<Order> {

  private static final Logger logger = LoggerFactory.getLogger(OrderEntity.class);

  private final String entityId;

  public OrderEntity(ValueEntityContext context) {
    this.entityId = context.entityId();
  }

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

  @PutMapping("/place")
  public Effect<Order> placeOrder(@RequestBody OrderRequest orderRequest) { (1)
    var orderId = commandContext().entityId();
    logger.info("Placing orderId={} request={}", orderId, orderRequest);
    var newOrder = new Order(
        orderId,
        false,
        true, (2)
        orderRequest.item(),
        orderRequest.quantity());
    return effects()
        .updateState(newOrder)
        .thenReply(newOrder);
  }

  @PostMapping("/confirm")
  public Effect<String> confirm() {
    var orderId = commandContext().entityId();
    logger.info("Confirming orderId={}", orderId);
    if (currentState().placed()) { (3)
      return effects()
          .updateState(currentState().confirm())
          .thenReply("Ok");
    } else {
      return effects().error(
          "No order found for '" + orderId + "'",
          ErrorCode.NOT_FOUND); (4)
    }
  }

  @PostMapping("/cancel")
  public Effect<String> cancel() {
    var orderId = commandContext().entityId();
    logger.info("Cancelling orderId={} currentState={}", orderId, currentState());
    if (!currentState().placed()) {
      return effects().error(
          "No order found for " + orderId,
          ErrorCode.NOT_FOUND); (5)
    } else if (currentState().confirmed()) {
      return effects().error(
          "Cannot cancel an already confirmed order",
          ErrorCode.BAD_REQUEST); (6)
    } else {
      return effects().updateState(emptyState())
          .thenReply("Ok"); (7)
    }
  }
}
1 The first method to look at is the placeOrder. It’s basically the creation of an order.
2 Note that you set the placed field to true.
3 When confirming an Order, you must ensure that the Order was created before.
4 If the Order was never created, it returns NOT_FOUND.
5 Cancelling an Order that was never placed also returns NOT_FOUND.
6 While cancelling an already confirmed order returns BAD_REQUEST.
7 Finally, if the Order is placed, but not confirmed, the cancel method resets the order to the emptyState.

Order Action and Timers

As said before, the OrderAction will act as a controller ahead of the Order Entity, receiving incoming messages, running some logic and then calling the Order Entity as needed.

Scheduling a timer

We will first look at OrderAction.placeOrder. Before delegating the request to the Order Entity, the Action creates a timer.

src/main/java/com/example/actions/OrderAction.java
@RequestMapping("/orders")
public class OrderAction extends Action {
  private String timerName(String orderId) {
    return "order-expiration-timer-" + orderId;
  }

  @PostMapping("/place")
  public Effect<Order> placeOrder(@RequestBody OrderRequest orderRequest) {

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

    CompletionStage<Done> timerRegistration = (2)
        timers().startSingleTimer(
          timerName(orderId), (3)
          Duration.ofSeconds(10), (4)
          componentClient.forAction().call(OrderAction::expire).params(orderId) (5)
        );


    var request = componentClient.forValueEntity(orderId).call(OrderEntity::placeOrder).params(orderRequest); (6)
    return effects().asyncReply( (7)
        timerRegistration
            .thenCompose(done -> request.execute())
            .thenApply(order -> order)
    );
  }
}
1 First you generate a random identifier for the OrderEntity. It will be used to identify the Order, but also as a unique name for the timer.
2 Call the timers API to register a new timer. Note that it returns CompletionStage<Done>. A successful completion means that Kalix registered the timer.
3 Order id is used to generate a unique name for the timer.
4 Set the delay you want for the timer to trigger.
5 Scheduled call to OrderAction.expire method. We will cover it in a while.
6 Pass on the request for the Order entity using the ComponentClient.
7 Finally, you build an asyncReply by composing the timerRegistration CompletionStage with a call to execute the request and place the order.

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

The sequence of actions is important here. If you 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, you 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 OrderAction.expire method implementation.

src/main/java/com/example/actions/OrderAction.java
@RequestMapping("/orders")
public class OrderAction extends Action {
  // ...

  @PostMapping("/expire/{orderId}")
  public Effect<String> expire(@PathVariable String orderId) {
    logger.info("Expiring order '{}'", orderId);
    var cancelRequest = componentClient.forValueEntity(orderId).call(OrderEntity::cancel);

    CompletionStage<String> reply =
        cancelRequest
            .execute() (1)
            .thenApply(cancelled -> "Ok") (2)
            .exceptionally(e -> { (3)
                  if (e.getCause() instanceof DeferredCallResponseException dcre &&
                      Set.of(ErrorCode.NOT_FOUND, ErrorCode.BAD_REQUEST).contains(dcre.errorCode())) {
                    // if NotFound or InvalidArgument, we don't need to re-try, and we can move on
                    // other kind of failures are not recovered and will trigger a re-try
                    return "Ok";
                  } else {
                    throw new RuntimeException(e);
                  }
                }
            );
    return effects().asyncReply(reply);
  }
}
1 When the OrderAction receives the expiration call, it immediately tries to cancel the Order. You use the execute() method to run it and this method returns a CompletionStage.
2 If the CompletionStage completes successfully, it’s all good and can simply return Ok. Since this method is returning normally, the timer will be considered as executed and will be removed from Kalix.
3 On the other hand, if the CompletionStage completes with a failure, you must decide if you will recover the call or not. If you recover it, the timer will be considered as completed. If you let the call fail, the timer will be re-scheduled.

You have seen the OrderEntity.cancel implementation, so you know that if you get a NOT_FOUND, it means that either the Order never existed or it was already cancelled (deleted). Or, you may get a BAD_REQUEST error, meaning that the order has been confirmed in the meantime. In both cases, you 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 OrderAction.expire will fail and the timer will be re-scheduled.

Whenever you implement a method that is called from a timer, you need to carefully handle errors inside that method. Failing to do so may cause the timer to keep re-scheduling. Therefore, you 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 OrderAction.confirm and OrderAction.cancel implementations. They are very similar. The only difference being the method they call on the OrderEntity.

src/main/java/com/example/actions/OrderAction.java
@RequestMapping("/orders")
public class OrderAction extends Action {
  // ...

  @PostMapping("/confirm/{orderId}")
  public Effect<String> confirm(@PathVariable String orderId) {
    logger.info("Confirming order '{}'", orderId);

    CompletionStage<String> reply =
      componentClient.forValueEntity(orderId).call(OrderEntity::confirm) (1)
        .execute()
            .thenCompose(req -> timers().cancel(timerName(orderId))) (2)
            .thenApply(done -> "Ok");

    return effects().asyncReply(reply);
  }

  @PostMapping("/cancel/{orderId}")
  public Effect<String> cancel(@PathVariable String orderId) {
    logger.info("Cancelling order '{}'", orderId);

    CompletionStage<String> reply =
      componentClient.forValueEntity(orderId).call(OrderEntity::cancel)
            .execute()
            .thenCompose(req -> timers().cancel(timerName(orderId)))
            .thenApply(done -> "Ok");

    return effects().asyncReply(reply);
  }
}
1 Call the Order entity to execute the command.
2 If it succeeds, remove the timer.

In both methods, you pass the request to the entity and when it completes, you 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 OrderAction.expire implementation, if the timer is triggered, but is obsolete, you will properly recover from it and signal to Kalix that the timer can be removed.

We could have completely ignored the timer when handling the confirmation or the cancelling. The registered timer would then be triggered at some point later and the expire method would have handled the fact that it has become obsolete. However, it’s always of good measure to do some housekeeping to save resources.