Repository: akka/akka-sdk
Pull request #348: feat: blocking timers calls for virtual threads
Author: login: johanandren
Labels: name: documentation
description: documentation related
name: java-sdk
Created: 2025-04-11T12:26:02Z | Merged: 2025-04-14T15:58:58Z

<!--
  Are there any relevant issues / PRs / mailing lists discussions?
  Please reference them here - but don't use `fixes` notation.
-->
References https://github.com/lightbend/kalix-runtime/issues/3719


On top of #340 



Diff:
diff --git a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimer.java b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimer.java
index eb9bdaed53..5af5443ea5 100644
--- a/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimer.java
+++ b/akka-javasdk-tests/src/test/java/akkajavasdk/components/workflowentities/WorkflowWithTimer.java
@@ -12,6 +12,7 @@
 import akka.javasdk.workflow.WorkflowContext;
 
 import java.time.Duration;
+import java.util.concurrent.CompletableFuture;
 
 @ComponentId("workflow-with-timer")
 public class WorkflowWithTimer extends Workflow<FailingCounterState> {
@@ -37,7 +38,9 @@ public WorkflowDef<FailingCounterState> definition() {
               .method(WorkflowWithTimer::pingWorkflow)
               .deferred(new CounterScheduledValue(12));
 
-          return timers().startSingleTimer("ping", Duration.ofSeconds(2), pingWorkflow);
+          timers().startSingleTimer("ping", Duration.ofSeconds(2), pingWorkflow);
+
+          return CompletableFuture.completedFuture(Done.done()); // FIXME remove once we have sync/blocking workflow calls
         })
         .andThen(Done.class, __ -> effects().pause())
         .timeout(Duration.ofMillis(50));
diff --git a/akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java b/akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
index 21c185c4e2..48d1e7cdda 100644
--- a/akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
+++ b/akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
@@ -30,7 +30,57 @@ public interface TimerScheduler {
    * @param delay delay, starting from now, in which the timer should be triggered
    * @param deferredCall a call to component that will be executed when the timer is triggered
    */
-  <I, O> CompletionStage<Done> startSingleTimer(
+  <I, O> void startSingleTimer(String name, Duration delay, DeferredCall<I, O> deferredCall);
+
+  /**
+   * Schedule a single timer. Timers allow for scheduling calls in the future. For example, to
+   * verify that some process have been completed or not.
+   *
+   * <p>Timers are persisted and are guaranteed to run at least once.
+   *
+   * <p>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 or the maxRetries limit
+   * is reached.
+   *
+   * <p>Each timer has a {@code name} and if a new timer with same {@code name} is registered the
+   * previous is cancelled.
+   *
+   * @param name unique name for the timer
+   * @param delay delay, starting from now, in which the timer should be triggered
+   * @param maxRetries Retry up to this many times before giving up
+   * @param deferredCall a call to component that will be executed when the timer is triggered
+   */
+  <I, O> void startSingleTimer(
+      String name, Duration delay, int maxRetries, DeferredCall<I, O> deferredCall);
+
+  /**
+   * Cancel an existing timer. This completes successfully if not timer is registered for the passed
+   * name.
+   */
+  void cancel(String name);
+
+  /**
+   * Schedule a single timer. Timers allow for scheduling calls in the future. For example, to
+   * verify that some process have been completed or not.
+   *
+   * <p>Timers are persisted and are guaranteed to run at least once.
+   *
+   * <p>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 an exponentially increasing delay, starting at 3 seconds with a max delay of 30 seconds.
+   * This process repeats until the call succeeds.
+   *
+   * <p>Each timer has a {@code name} and if a new timer with same {@code name} is registered the
+   * previous is cancelled.
+   *
+   * @param name unique name for the timer
+   * @param delay delay, starting from now, in which the timer should be triggered
+   * @param deferredCall a call to component that will be executed when the timer is triggered
+   * @return A completion stage that is completed successfully or failed, once the timer has been
+   *     scheduled
+   */
+  <I, O> CompletionStage<Done> startSingleTimerAsync(
       String name, Duration delay, DeferredCall<I, O> deferredCall);
 
   /**
@@ -51,13 +101,15 @@ <I, O> CompletionStage<Done> startSingleTimer(
    * @param delay delay, starting from now, in which the timer should be triggered
    * @param maxRetries Retry up to this many times before giving up
    * @param deferredCall a call to component that will be executed when the timer is triggered
+   * @return A completion stage that is completed successfully or failed, once the timer has been
+   *     scheduled
    */
-  <I, O> CompletionStage<Done> startSingleTimer(
+  <I, O> CompletionStage<Done> startSingleTimerAsync(
       String name, Duration delay, int maxRetries, DeferredCall<I, O> deferredCall);
 
   /**
    * Cancel an existing timer. This completes successfully if not timer is registered for the passed
    * name.
    */
-  CompletionStage<Done> cancel(String name);
+  CompletionStage<Done> cancelAsync(String name);
 }
diff --git a/akka-javasdk/src/main/scala/akka/javasdk/impl/timer/TimerSchedulerImpl.scala b/akka-javasdk/src/main/scala/akka/javasdk/impl/timer/TimerSchedulerImpl.scala
index 9c72d2eefd..49b30a49f0 100644
--- a/akka-javasdk/src/main/scala/akka/javasdk/impl/timer/TimerSchedulerImpl.scala
+++ b/akka-javasdk/src/main/scala/akka/javasdk/impl/timer/TimerSchedulerImpl.scala
@@ -4,16 +4,19 @@
 
 package akka.javasdk.impl.timer
 
+import akka.Done
+
 import java.time.Duration
-import java.util.concurrent.CompletionStage
 import scala.jdk.FutureConverters.FutureOps
-import akka.Done
 import akka.annotation.InternalApi
 import akka.javasdk.DeferredCall
 import akka.javasdk.Metadata
+import akka.javasdk.impl.ErrorHandling
 import akka.javasdk.impl.client.DeferredCallImpl
 import akka.javasdk.timer.TimerScheduler
 
+import java.util.concurrent.CompletionStage
+import java.util.concurrent.ExecutionException
 import scala.jdk.DurationConverters.JavaDurationOps
 
 /**
@@ -23,31 +26,51 @@ import scala.jdk.DurationConverters.JavaDurationOps
 private[akka] final class TimerSchedulerImpl(val timerClient: akka.runtime.sdk.spi.TimerClient, val metadata: Metadata)
     extends TimerScheduler {
 
-  override def startSingleTimer[I, O](
+  override def startSingleTimer[I, O](name: String, delay: Duration, deferredCall: DeferredCall[I, O]): Unit =
+    startSingleTimer(name, delay, 0, deferredCall)
+
+  override def startSingleTimerAsync[I, O](
       name: String,
       delay: Duration,
       deferredCall: DeferredCall[I, O]): CompletionStage[Done] =
-    startSingleTimer(name, delay, 0, deferredCall)
+    startSingleTimerAsync(name, delay, 0, deferredCall)
 
   override def startSingleTimer[I, O](
       name: String,
       delay: Duration,
       maxRetries: Int,
-      deferredCall: DeferredCall[I, O]): CompletionStage[Done] = {
+      deferredCall: DeferredCall[I, O]): Unit = {
+    try {
+      startSingleTimerAsync[I, O](name, delay, maxRetries, deferredCall).toCompletableFuture
+        .get() // timeout handled by runtime
+    } catch {
+      case ex: ExecutionException => throw ErrorHandling.unwrapExecutionException(ex)
+    }
+  }
 
+  override def startSingleTimerAsync[I, O](
+      name: String,
+      delay: Duration,
+      maxRetries: Int,
+      deferredCall: DeferredCall[I, O]): CompletionStage[Done] =
     deferredCall match {
       case embeddedDeferredCall: DeferredCallImpl[I, O] =>
-        timerClient.startSingleTimer(name, delay.toScala, maxRetries, embeddedDeferredCall.deferredRequest()).asJava
-
+        timerClient
+          .startSingleTimer(name, delay.toScala, maxRetries, embeddedDeferredCall.deferredRequest())
+          .asJava
       case unknown =>
         // should never happen, but needs to make compiler happy
         throw new IllegalStateException(s"Unknown DeferredCall implementation: $unknown")
     }
 
+  def cancel(name: String): Unit = {
+    try {
+      cancelAsync(name).toCompletableFuture.get() // timeout handled by runtime
+    } catch {
+      case ex: ExecutionException => throw ErrorHandling.unwrapExecutionException(ex)
+    }
   }
 
-  def cancel(name: String): CompletionStage[Done] = {
+  override def cancelAsync(name: String): CompletionStage[Done] =
     timerClient.removeTimer(name).asJava
-  }
-
 }
diff --git a/docs/src/modules/java/pages/timed-actions.adoc b/docs/src/modules/java/pages/timed-actions.adoc
index 27c327c612..006c38c151 100644
--- a/docs/src/modules/java/pages/timed-actions.adoc
+++ b/docs/src/modules/java/pages/timed-actions.adoc
@@ -60,11 +60,11 @@ include::example$reliable-timers/src/main/java/com/example/api/OrderEndpoint.jav
 ----
 <1> Declares `TimerScheduler` alongside `ComponentClient`, both provided by Akka.
 <2> Generates a unique identifier for the order and timer.
-<3> Calls the `TimerScheduler` API to register a new timer, which returns `CompletionStage<Done>`. A successful completion indicates that Akka registered the timer.
+<3> Calls the `TimerScheduler` API to register a new timer.
 <4> Uses the order id to generate a unique timer name.
 <5> Sets the timer delay.
 <6> Schedules a deferred call to the Timed Action component, covered next.
-<7> Composes the `timerRegistration` `CompletionStage` with a call to `OrderEntity` to place the order.
+<7> Call to `OrderEntity` to place the order.
 
 Akka registers the timer before the order is placed. This ensures that, if timer registration fails due to network issues, no untracked order remains. The inverse failure scenario — registering the timer but failing to place the order — is mitigated by handling potential failures in the `OrderEntity.cancel` method (xref:#_cancel_order_impl[see further]).
 
diff --git a/samples/choreography-saga-quickstart/src/main/java/user/registry/application/UniqueEmailConsumer.java b/samples/choreography-saga-quickstart/src/main/java/user/registry/application/UniqueEmailConsumer.java
index 39d30b961d..edf955ef86 100644
--- a/samples/choreography-saga-quickstart/src/main/java/user/registry/application/UniqueEmailConsumer.java
+++ b/samples/choreography-saga-quickstart/src/main/java/user/registry/application/UniqueEmailConsumer.java
@@ -57,17 +57,17 @@ public Effect onChange(UniqueEmail email) {
           .forKeyValueEntity(email.address())
           .method(UniqueEmailEntity::cancelReservation).deferred();
 
-      var timer = timers().startSingleTimer(
+      timers().startSingleTimer(
         timerId,
         delay,
         callToUnReserve);
 
-      return effects().asyncDone(timer);
+      return effects().done();
 
     } else if (email.isConfirmed()) {
       logger.info("Email is already confirmed, deleting timer (if exists) '{}'", timerId);
-      var cancellation = timers().cancel(timerId);
-      return effects().asyncDone(cancellation);
+      timers().cancel(timerId);
+      return effects().done();
 
     } else {
       // Email is not reserved, so we don't need to do anything
diff --git a/samples/doc-snippets/src/main/java/com/example/application/MyConsumer.java b/samples/doc-snippets/src/main/java/com/example/application/MyConsumer.java
index 41224cc8ac..5dd66594b1 100644
--- a/samples/doc-snippets/src/main/java/com/example/application/MyConsumer.java
+++ b/samples/doc-snippets/src/main/java/com/example/application/MyConsumer.java
@@ -1,14 +1,9 @@
 package com.example.application;
 
-import akka.Done;
 import akka.javasdk.annotations.ComponentId;
 import akka.javasdk.consumer.Consumer;
 
 import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
-
-import static akka.Done.done;
 
 @ComponentId("my-consumer")
 public class MyConsumer extends Consumer {
@@ -17,8 +12,7 @@ record Event() {
   }
 
   record SomeService() {
-    public CompletionStage<Done> doSomething(Event event, String token) {
-      return CompletableFuture.completedFuture(done());
+    public void doSomething(Event event, String token) {
     }
   }
 
@@ -29,8 +23,8 @@ public Effect handle(Event event) {
     var entityId = messageContext().eventSubject().get();
     var sequenceNumber = messageContext().metadata().asCloudEvent().sequence().get();
     var token = UUID.nameUUIDFromBytes((entityId + sequenceNumber).getBytes()); // <1>
-    return effects().asyncDone(
-      someService.doSomething(event, token.toString()));
+    someService.doSomething(event, token.toString());
+    return effects().done();
   }
   // end::deterministic-hashing[]
 }
diff --git a/samples/endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java b/samples/endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java
index cbb8ecbe4d..f0e6137b6d 100644
--- a/samples/endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java
+++ b/samples/endpoint-jwt/src/main/java/hellojwt/api/HelloJwtEndpoint.java
@@ -14,9 +14,6 @@
 
 // end::accessing-claims[]
 
-import java.util.concurrent.CompletionStage;
-import static java.util.concurrent.CompletableFuture.completedStage;
-
 // Opened up for access from the public internet to make the sample service easy to try out.
 // For actual services meant for production this must be carefully considered, and often set more limited
 @Acl(allow = @Acl.Matcher(principal = Acl.Principal.INTERNET))
@@ -29,8 +26,8 @@ public class HelloJwtEndpoint extends AbstractHttpEndpoint {
   // end::accessing-claims[]
 
   @Get("/")
-  public CompletionStage<String> hello() {
-    return completedStage("Hello, World!");
+  public String hello() {
+    return "Hello, World!";
   }
 
   // tag::accessing-claims[]
diff --git a/samples/reliable-timers/src/main/java/com/example/api/OrderEndpoint.java b/samples/reliable-timers/src/main/java/com/example/api/OrderEndpoint.java
index 68b774411d..a792bf5f17 100644
--- a/samples/reliable-timers/src/main/java/com/example/api/OrderEndpoint.java
+++ b/samples/reliable-timers/src/main/java/com/example/api/OrderEndpoint.java
@@ -1,6 +1,5 @@
 package com.example.api;
 
-import akka.Done;
 import akka.http.javadsl.model.HttpResponse;
 import akka.javasdk.annotations.Acl;
 import akka.javasdk.annotations.http.HttpEndpoint;
@@ -16,8 +15,6 @@
 
 import java.time.Duration;
 import java.util.UUID;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.CompletionStage;
 
 // Opened up for access from the public internet to make the sample service easy to try out.
 // For actual services meant for production this must be carefully considered, and often set more limited
@@ -43,18 +40,17 @@ private String timerName(String orderId) {
   }
 
   @Post
-  public CompletionStage<Order> placeOrder(OrderRequest orderRequest) {
+  public 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>
-      );
+    timerScheduler.startSingleTimer( // <3>
+      timerName(orderId), // <4>
+      Duration.ofSeconds(10), // <5>
+      componentClient.forTimedAction()
+        .method(OrderTimedAction::expireOrder)
+        .deferred(orderId) // <6>
+    );
 
     // end::place-order[]
     logger.info(
@@ -64,13 +60,11 @@ public CompletionStage<Order> placeOrder(OrderRequest orderRequest) {
       orderId);
     // tag::place-order[]
 
-    return
-      timerRegistration.thenCompose(done ->
-          componentClient.forKeyValueEntity(orderId)
-            .method(OrderEntity::placeOrder)
-            .invokeAsync(orderRequest)) // <7>
-        .thenApply(order -> order);
+    var order = componentClient.forKeyValueEntity(orderId)
+        .method(OrderEntity::placeOrder)
+        .invoke(orderRequest); // <7>
 
+    return order;
   }
   // end::place-order[]
 
@@ -78,34 +72,36 @@ public CompletionStage<Order> placeOrder(OrderRequest orderRequest) {
   // ...
 
   @Post("/{orderId}/confirm")
-  public CompletionStage<HttpResponse> confirm(String orderId) {
+  public HttpResponse confirm(String orderId) {
     // end::confirm-order[]
     logger.info("Confirming order '{}'", orderId);
     // tag::confirm-order[]
-    var result = componentClient.forKeyValueEntity(orderId)
-            .method(OrderEntity::confirm).invoke(); // <1>
-
-    return switch (result) {
-      case OrderEntity.Result.Ok __ -> timerScheduler.cancel(timerName(orderId)) // <2>
-          .thenApply(___ -> HttpResponses.ok());
+    var confirmResult = componentClient.forKeyValueEntity(orderId)
+        .method(OrderEntity::confirm).invoke(); // <1>
+
+    return switch (confirmResult) {
+      case OrderEntity.Result.Ok ignored -> {
+        timerScheduler.cancel(timerName(orderId)); // <2>
+        yield HttpResponses.ok();
+      }
       case OrderEntity.Result.NotFound notFound ->
-          CompletableFuture.completedFuture(HttpResponses.notFound(notFound.message()));
+          HttpResponses.notFound(notFound.message());
       case OrderEntity.Result.Invalid invalid ->
-          CompletableFuture.completedFuture(HttpResponses.badRequest(invalid.message()));
+          HttpResponses.badRequest(invalid.message());
     };
   }
   // end::confirm-order[]
 
   @Post("/{orderId}/cancel")
-  public CompletionStage<HttpResponse> cancel(String orderId) {
+  public 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());
+    componentClient.forKeyValueEntity(orderId)
+        .method(OrderEntity::cancel)
+        .invoke();
+    timerScheduler.cancel(timerName(orderId));
+
+    return HttpResponses.ok();
   }
 
 // tag::timers[]
diff --git a/samples/tracing/src/main/java/com/example/tracing/api/TracingEndpoint.java b/samples/tracing/src/main/java/com/example/tracing/api/TracingEndpoint.java
index 6d50a5f9d7..c1328938fe 100644
--- a/samples/tracing/src/main/java/com/example/tracing/api/TracingEndpoint.java
+++ b/samples/tracing/src/main/java/com/example/tracing/api/TracingEndpoint.java
@@ -1,6 +1,5 @@
 package com.example.tracing.api;
 
-import akka.Done;
 import akka.http.javadsl.model.HttpResponse;
 import akka.javasdk.annotations.Acl;
 import akka.javasdk.annotations.http.HttpEndpoint;
@@ -39,12 +38,11 @@ private record PostId(String id){}
 
 
     @Post("/")
-    public CompletionStage<Done> postDelayed(PostId id) {
-        return timerScheduler.startSingleTimer(
-                UUID.randomUUID().toString(), //not planning to cancel the timer
-                Duration.ofSeconds(1L),
-                componentClient.forTimedAction().method(TracingAction::callAnotherService).deferred(id.id)
-        );
+    public void postDelayed(PostId id) {
+        timerScheduler.startSingleTimer(
+            UUID.randomUUID().toString(), //not planning to cancel the timer
+            Duration.ofSeconds(1L),
+            componentClient.forTimedAction().method(TracingAction::callAnotherService).deferred(id.id));
     }
 
     @Post("/custom/{id}")
diff --git a/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java b/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java
index e442bafa7f..646e974780 100644
--- a/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java
+++ b/samples/transfer-workflow-compensation/src/main/java/com/example/transfer/application/TransferWorkflow.java
@@ -48,10 +48,11 @@ public WorkflowDef<TransferState> definition() {
           logger.info("Running withdraw: {}", cmd);
 
           // cancelling the timer in case it was scheduled
-          return timers().cancel("acceptationTimout-" + currentState().transferId()).thenCompose(__ ->
-            componentClient.forEventSourcedEntity(currentState().transfer().from())
+          timers().cancel("acceptationTimout-" + currentState().transferId());
+
+          return componentClient.forEventSourcedEntity(currentState().transfer().from())
               .method(WalletEntity::withdraw)
-              .invokeAsync(cmd));
+              .invokeAsync(cmd);
         })
         .andThen(WalletResult.class, result -> {
           switch (result) {
@@ -149,12 +150,13 @@ public WorkflowDef<TransferState> definition() {
       step("wait-for-acceptation")
         .asyncCall(() -> {
           String transferId = currentState().transferId();
-          return timers().startSingleTimer(
+          timers().startSingleTimer(
             "acceptationTimeout-" + transferId,
             ofHours(8),
             componentClient.forWorkflow(transferId)
               .method(TransferWorkflow::acceptationTimeout)
               .deferred()); // <1>
+          return CompletableFuture.completedFuture(Done.done()); // FIXME remove once we have sync/blocking workflow calls
         })
         .andThen(Done.class, __ ->
           effects().pause()); // <2>


Reviews:
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T10:59:12Z
user: login: aludwiko
body: LGTM with a question
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:32:30Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T12:55:55Z
user: login: patriknw
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T13:01:56Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:47:49Z
user: login: johanandren
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-14T14:47:56Z

Review comments:
diff_hunk: @@ -23,21 +21,22 @@ import scala.jdk.DurationConverters.JavaDurationOps
 private[akka] final class TimerSchedulerImpl(val timerClient: akka.runtime.sdk.spi.TimerClient, val metadata: Metadata)
     extends TimerScheduler {
 
-  override def startSingleTimer[I, O](
-      name: String,
-      delay: Duration,
-      deferredCall: DeferredCall[I, O]): CompletionStage[Done] =
+  override def startSingleTimer[I, O](name: String, delay: Duration, deferredCall: DeferredCall[I, O]): Unit =
     startSingleTimer(name, delay, 0, deferredCall)
 
   override def startSingleTimer[I, O](
       name: String,
       delay: Duration,
       maxRetries: Int,
-      deferredCall: DeferredCall[I, O]): CompletionStage[Done] = {
+      deferredCall: DeferredCall[I, O]): Unit = {
 
     deferredCall match {
       case embeddedDeferredCall: DeferredCallImpl[I, O] =>
-        timerClient.startSingleTimer(name, delay.toScala, maxRetries, embeddedDeferredCall.deferredRequest()).asJava
+        timerClient
+          .startSingleTimer(name, delay.toScala, maxRetries, embeddedDeferredCall.deferredRequest())
+          .asJava
+          .toCompletableFuture
+          .get() // timeout handled by runtime
path: akka-javasdk/src/main/scala/akka/javasdk/impl/timer/TimerSchedulerImpl.scala
user: login: johanandren
body: I should unbox exceptions here like in the component client
created_at: 2025-04-14T10:59:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,7 +28,7 @@ public interface TimerScheduler {
    * @param delay delay, starting from now, in which the timer should be triggered
    * @param deferredCall a call to component that will be executed when the timer is triggered
    */
-  <I, O> CompletionStage<Done> startSingleTimer(
+  <I, O> void startSingleTimer(
path: akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
user: login: aludwiko
body: Shouldn't we have also `startSingleTimerAsync`? not sure what for, but maybe it will be easier to migrate existing code, first change the method name and later use the blocking api and refactor the code. 
created_at: 2025-04-14T12:31:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,7 +28,7 @@ public interface TimerScheduler {
    * @param delay delay, starting from now, in which the timer should be triggered
    * @param deferredCall a call to component that will be executed when the timer is triggered
    */
-  <I, O> CompletionStage<Done> startSingleTimer(
+  <I, O> void startSingleTimer(
path: akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
user: login: johanandren
body: Yeah, I skipped it because couldn't think of a reasonable use case, but easy migration might be useful enough.
created_at: 2025-04-14T12:55:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,7 +28,7 @@ public interface TimerScheduler {
    * @param delay delay, starting from now, in which the timer should be triggered
    * @param deferredCall a call to component that will be executed when the timer is triggered
    */
-  <I, O> CompletionStage<Done> startSingleTimer(
+  <I, O> void startSingleTimer(
path: akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
user: login: patriknw
body: Sounds good to always have both. Someone might want to fire off several timers at the same time, or use as part of other CompletionStage composition.
created_at: 2025-04-14T13:01:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -30,7 +28,7 @@ public interface TimerScheduler {
    * @param delay delay, starting from now, in which the timer should be triggered
    * @param deferredCall a call to component that will be executed when the timer is triggered
    */
-  <I, O> CompletionStage<Done> startSingleTimer(
+  <I, O> void startSingleTimer(
path: akka-javasdk/src/main/java/akka/javasdk/timer/TimerScheduler.java
user: login: johanandren
body: Reintroduced in [2ee912a](https://github.com/akka/akka-sdk/pull/348/commits/2ee912ae08c77644b5e5aac23787c1a6e638f7d3)
created_at: 2025-04-14T14:47:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -23,21 +21,22 @@ import scala.jdk.DurationConverters.JavaDurationOps
 private[akka] final class TimerSchedulerImpl(val timerClient: akka.runtime.sdk.spi.TimerClient, val metadata: Metadata)
     extends TimerScheduler {
 
-  override def startSingleTimer[I, O](
-      name: String,
-      delay: Duration,
-      deferredCall: DeferredCall[I, O]): CompletionStage[Done] =
+  override def startSingleTimer[I, O](name: String, delay: Duration, deferredCall: DeferredCall[I, O]): Unit =
     startSingleTimer(name, delay, 0, deferredCall)
 
   override def startSingleTimer[I, O](
       name: String,
       delay: Duration,
       maxRetries: Int,
-      deferredCall: DeferredCall[I, O]): CompletionStage[Done] = {
+      deferredCall: DeferredCall[I, O]): Unit = {
 
     deferredCall match {
       case embeddedDeferredCall: DeferredCallImpl[I, O] =>
-        timerClient.startSingleTimer(name, delay.toScala, maxRetries, embeddedDeferredCall.deferredRequest()).asJava
+        timerClient
+          .startSingleTimer(name, delay.toScala, maxRetries, embeddedDeferredCall.deferredRequest())
+          .asJava
+          .toCompletableFuture
+          .get() // timeout handled by runtime
path: akka-javasdk/src/main/scala/akka/javasdk/impl/timer/TimerSchedulerImpl.scala
user: login: johanandren
body: Added in [2ee912a](https://github.com/akka/akka-sdk/pull/348/commits/2ee912ae08c77644b5e5aac23787c1a6e638f7d3)
created_at: 2025-04-14T14:47:56Z
author_association: CONTRIBUTOR

PR comments:
user: login: patriknw
created_at: 2025-04-14T08:49:31Z
body: why don't you pick the branch of the other pr as base branch, to simplify reviewing?
author_association: CONTRIBUTOR
user: login: johanandren
created_at: 2025-04-14T10:38:57Z
body: Ah, clever, didn't think of that. Will do for all the PRs
author_association: CONTRIBUTOR