Determinism during replay¶
The Durable Execution SDK checkpoints your code so that it can terminate the current invocation and not consume compute while it waits for a timed duration or processing result to be ready. The AWS Lambda backend re-invokes the function when it is ready to resume processing.
Durable functions run your handler from the top on every invocation. A step that completed in an earlier invocation returns its checkpointed result on replay without re-executing the code inside the step. Anything outside a step or other durable operation runs every time the function replays.
For replay to follow the same path, the code that runs every time has to produce the same values. That is determinism. Non-deterministic code inside your handler body can send control flow down a different branch on replay, so a downstream step runs with the wrong inputs.
Handler code must be deterministic¶
Any code that is not inside a durable operation must be a pure function of the handler inputs and the results of completed operations. Anything that depends on wall-clock time, a random source, an external service, the local file system, or mutable global state is non-deterministic and must run inside a durable operation.
Concrete examples of code that is not deterministic:
- Time and identity
Date.now(),time.time(),Instant.now(),UUIDgeneration, or anything that returns a different value each call. - External I/O HTTP calls, database reads, AWS SDK calls, reading files.
- Random numbers
Math.random(),random.random(),Random.
Non-deterministic code must be in a durable operation¶
A step checkpoints its return value. On replay the step returns the checkpointed value instead of running the underlying code. Wrapping a non-deterministic call in a step means the value will always be the result of the first successful completion of that code.
import { withDurableExecution, DurableContext } from "@aws/durable-execution-sdk-js";
import { randomUUID } from "crypto";
export const handler = withDurableExecution(
async (event: { amount: number }, context: DurableContext) => {
const transactionId = await context.step(
"generate-transaction-id",
async () => randomUUID(),
);
const receipt = await context.step("charge", async () => {
return charge(event.amount, transactionId);
});
return { transactionId, receipt };
},
);
import uuid
from aws_durable_execution_sdk_python import DurableContext, durable_execution, durable_step
from aws_durable_execution_sdk_python.types import StepContext
@durable_step
def generate_transaction_id(ctx: StepContext) -> str:
return str(uuid.uuid4())
@durable_step
def charge(ctx: StepContext, amount: float, transaction_id: str) -> dict:
return payment_service.charge(amount, transaction_id)
@durable_execution
def handler(event: dict, context: DurableContext) -> dict:
transaction_id = context.step(generate_transaction_id())
receipt = context.step(charge(event["amount"], transaction_id))
return {"transactionId": transaction_id, "receipt": receipt}
import software.amazon.lambda.durable.DurableContext;
import software.amazon.lambda.durable.DurableHandler;
import java.util.UUID;
public class ChargeHandler implements DurableHandler<ChargeInput, Receipt> {
@Override
public Receipt handle(ChargeInput input, DurableContext context) {
String transactionId = context.step(
"generate-transaction-id",
String.class,
ctx -> UUID.randomUUID().toString());
return context.step(
"charge",
Receipt.class,
ctx -> paymentService.charge(input.amount(), transactionId));
}
}
Because the SDK checkpoints the result of generate-transaction-id, every replay sees
the same transactionId and the charge step receives the same argument. Without the
wrapper, UUID.randomUUID() would produce a new value on every replay and the
downstream step would either double-charge or hit an idempotency error from the payment
service.
Tip
Wrap every non-deterministic call inside a step.
Pass data through return values, not closures¶
Steps checkpoint their return value. On replay the SDK returns the cached value in place of running the step body. Replay discards any state a step body writes to variables outside itself through a closure, because the write happens inside the body and the body does not run. The first invocation succeeds. Replay leaves the outer state at its initial value.
Return the value from the step. Read the return value in the handler and update outer state there.
// Wrong: the step body pushes to an outer list. Replay returns the
// cached undefined without running the body, so the list stays empty
// and the handler returns { receipts: [] } on replay.
export const handler = withDurableExecution(async (event, context) => {
const receipts: string[] = [];
for (const item of event.items) {
await context.step(`save-${item.id}`, async () => {
const receipt = await saveItem(item);
receipts.push(receipt.id);
});
}
return { receipts };
});
// Right: the step returns the receipt id. The handler appends the
// returned value to the outer list, which replay rebuilds from the
// cached step results.
export const handler = withDurableExecution(async (event, context) => {
const receipts: string[] = [];
for (const item of event.items) {
const receiptId = await context.step(`save-${item.id}`, async () => {
const receipt = await saveItem(item);
return receipt.id;
});
receipts.push(receiptId);
}
return { receipts };
});
# Wrong: the step body mutates an outer list passed by reference.
# Replay returns the cached None without running the body, so the
# list stays empty and the handler returns {"receipts": []} on replay.
@durable_step
def save_and_track(ctx: StepContext, item: dict, target: list) -> None:
receipt = save_item(item)
target.append(receipt["id"])
@durable_execution
def handler(event, context: DurableContext) -> dict:
receipts: list[str] = []
for item in event["items"]:
context.step(
save_and_track(item, receipts),
name=f"save-{item['id']}",
)
return {"receipts": receipts}
# Right: the step returns the receipt id. The handler appends the
# returned value to the outer list, which replay rebuilds from the
# cached step results.
@durable_step
def save_and_return_id(ctx: StepContext, item: dict) -> str:
receipt = save_item(item)
return receipt["id"]
@durable_execution
def handler(event, context: DurableContext) -> dict:
receipts: list[str] = []
for item in event["items"]:
receipt_id = context.step(
save_and_return_id(item),
name=f"save-{item['id']}",
)
receipts.append(receipt_id)
return {"receipts": receipts}
// Wrong: the step body adds to an outer list through a closure.
// Replay returns the cached null without running the body, so the
// list stays empty and the handler returns an empty receipts list
// on replay.
List<String> receipts = new ArrayList<>();
for (Item item : input.items()) {
context.step(
"save-" + item.id(),
Void.class,
ctx -> {
String receiptId = saveItem(item);
receipts.add(receiptId);
return null;
});
}
return new Result(receipts);
// Right: the step returns the receipt id. The handler adds the
// returned value to the outer list, which replay rebuilds from the
// cached step results.
List<String> receipts = new ArrayList<>();
for (Item item : input.items()) {
String receiptId = context.step(
"save-" + item.id(),
String.class,
ctx -> saveItem(item));
receipts.add(receiptId);
}
return new Result(receipts);
For processing a list of independent items,
map is a simpler choice than an
explicit loop. It runs the per-item operation in parallel, checkpoints each result, and
returns a BatchResult you can reduce. Use an explicit loop when items depend on each
other, such as a running total or chained transformations. Keep the loop deterministic.
Each step must produce the same result on replay.
Danger
Side effects inside a step body that write to outer state do not re-run on replay. The first invocation looks correct because the body runs and the write lands. Replay returns the cached result and skips the body, so the outer state stays at its initial value.
Keep branches stable across replay¶
Control flow decisions made outside steps must depend only on deterministic inputs. If
an if or switch depends on something non-deterministic, replay can walk a different
branch and attempt to return results from operations that never ran. Wrap the
non-deterministic decision into a step and branch on the step's return value.
// Wrong: replay may see different values of `new Date()`.
if (new Date().getHours() < 12) {
await context.step("morning-work", async () => runMorning());
} else {
await context.step("afternoon-work", async () => runAfternoon());
}
// Right: the SDK checkpoints the decision.
const shift = await context.step("pick-shift", async () => {
return new Date().getHours() < 12 ? "morning" : "afternoon";
});
if (shift === "morning") {
await context.step("morning-work", async () => runMorning());
} else {
await context.step("afternoon-work", async () => runAfternoon());
}
# Wrong: replay may see different values of datetime.now().
from datetime import datetime
if datetime.now().hour < 12:
context.step(run_morning(), name="morning-work")
else:
context.step(run_afternoon(), name="afternoon-work")
# Right: the SDK checkpoints the decision.
shift = context.step(pick_shift(), name="pick-shift")
if shift == "morning":
context.step(run_morning(), name="morning-work")
else:
context.step(run_afternoon(), name="afternoon-work")
@durable_step
def pick_shift(ctx: StepContext) -> str:
return "morning" if datetime.now().hour < 12 else "afternoon"
// Wrong: replay may see different values of LocalTime.now().
if (LocalTime.now().getHour() < 12) {
context.step("morning-work", Void.class, ctx -> { runMorning(); return null; });
} else {
context.step("afternoon-work", Void.class, ctx -> { runAfternoon(); return null; });
}
// Right: the SDK checkpoints the decision.
String shift = context.step(
"pick-shift",
String.class,
ctx -> LocalTime.now().getHour() < 12 ? "morning" : "afternoon");
if ("morning".equals(shift)) {
context.step("morning-work", Void.class, ctx -> { runMorning(); return null; });
} else {
context.step("afternoon-work", Void.class, ctx -> { runAfternoon(); return null; });
}
The same rule applies to reading from external services. Fetching a flag from a database outside a step risks replaying against a changed value. Fetch inside a step, branch on the returned value.
Warning
Feature flags, environment variables read at runtime, and configuration pulled from a remote store could all change between the first invocation and a replay. Capture the value inside a step so the evaluation criteria are stable.
See also¶
- Idempotency and retries How retry behavior interacts with determinism.
- Step design Naming, granularity, and the step boundary.
- Step operation reference