Steps execute your code and checkpoint the result. On replay, results from completed checkpoints are returned without re-execution.
// Basic step (blocks until complete)
var result = ctx.step("fetch-user", User.class, () -> userService.getUser(userId));
// Step with custom configuration (retries, semantics, serialization)
var result = ctx.step("call-api", Response.class,
() -> externalApi.call(request),
StepConfig.builder()
.retryStrategy(...)
.semantics(...)
.build());See Step Configuration for retry strategies, delivery semantics, and per-step serialization options.
stepAsync() starts a step in the background and returns a DurableFuture<T>. This enables concurrent execution patterns.
// Start multiple operations concurrently
DurableFuture<User> userFuture = ctx.stepAsync("fetch-user", User.class,
() -> userService.getUser(userId));
DurableFuture<List<Order>> ordersFuture = ctx.stepAsync("fetch-orders",
new TypeToken<List<Order>>() {}, () -> orderService.getOrders(userId));
// Both operations run concurrently
// Block and get results when needed
User user = userFuture.get();
List<Order> orders = ordersFuture.get();Configure step behavior with StepConfig:
ctx.step("my-step", Result.class, () -> doWork(),
StepConfig.builder()
.retryStrategy(...) // How to handle failures
.semantics(...) // At-least-once vs at-most-once
.serDes(...) // Custom serialization
.build());Configure how steps handle transient failures:
// No retry - fail immediately (default)
var noRetries = StepConfig.builder().retryStrategy(RetryStrategies.Presets.NO_RETRY).build()
// Exponential backoff with jitter
var customRetries = StepConfig.builder()
.retryStrategy(RetryStrategies.exponentialBackoff(
5, // max attempts
Duration.ofSeconds(2), // initial delay
Duration.ofSeconds(30), // max delay
2.0, // backoff multiplier
JitterStrategy.FULL)) // randomize delays
.build()Control how steps behave when interrupted mid-execution:
| Semantic | Behavior | Use Case |
|---|---|---|
AT_LEAST_ONCE_PER_RETRY (default) |
Re-executes step if interrupted before completion | Idempotent operations (database upserts, API calls with idempotency keys) |
AT_MOST_ONCE_PER_RETRY |
Never re-executes; throws StepInterruptedException if interrupted |
Non-idempotent operations (sending emails, charging payments) |
// Default: at-least-once per retry (step may re-run if interrupted)
var result = ctx.step("idempotent-update", Result.class,
() -> database.upsert(record));
// At-most-once per retry
var result = ctx.step("send-email", Result.class,
() -> emailService.send(notification),
StepConfig.builder()
.semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY)
.build());Important: These semantics apply per retry attempt, not per overall execution:
- AT_LEAST_ONCE_PER_RETRY: The step executes at least once per retry. If the step succeeds but checkpointing fails (e.g., sandbox crash), the step re-executes on replay.
- AT_MOST_ONCE_PER_RETRY: A checkpoint is created before execution. If failure occurs after checkpoint but before completion, the step is skipped on replay and
StepInterruptedExceptionis thrown.
To achieve step-level at-most-once semantics, combine with a no-retry strategy:
// True at-most-once: step executes at most once, ever
var result = ctx.step("charge-payment", Result.class,
() -> paymentService.charge(amount),
StepConfig.builder()
.semantics(StepSemantics.AT_MOST_ONCE_PER_RETRY)
.retryStrategy(RetryStrategies.Presets.NO_RETRY)
.build());Without this, a step using AT_MOST_ONCE_PER_RETRY with retries enabled could still execute multiple times across different retry attempts.
When a step returns a parameterized type like List<User>, use TypeToken to preserve the type information:
var users = ctx.step("fetch-users", new TypeToken<List<User>>() {},
() -> userService.getAllUsers());
var orderMap = ctx.step("fetch-orders", new TypeToken<Map<String, Order>>() {},
() -> orderService.getOrdersByCustomer());This is needed for the SDK to deserialize a checkpointed result and get the exact type to reconstruct. See TypeToken and Type Erasure for technical details.