Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions docs/patterns/best-practices/determinism.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,14 @@ service.

## Pass data through return values, not closures

State outside a step resets to its initial value on replay. Steps return their cached
results, but assignments, mutations, and pushes that happen outside steps run again on
every invocation. Although this pattern looks like it works on the first invocation, it
breaks as soon as the workflow replays after a crash or a wait.
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.

=== "TypeScript"

Expand Down Expand Up @@ -98,8 +102,10 @@ Each step must produce the same result on replay.

!!! danger

Mutating state outside a step fails silently. The first invocation looks correct. Replay
resets the mutation while steps return their cached results.
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

Expand Down
32 changes: 24 additions & 8 deletions examples/java/patterns/determinism/return-value-passing.java
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
// Right: each step returns the new running total.
double total = 0.0;
// 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()) {
final double running = total;
total = context.step(
context.step(
"save-" + item.id(),
Double.class,
Void.class,
ctx -> {
saveItem(item);
return running + item.price();
String receiptId = saveItem(item);
receipts.add(receiptId);
return null;
});
}
return new Result(total);
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);
39 changes: 26 additions & 13 deletions examples/python/patterns/determinism/return-value-passing.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,39 @@
# Wrong: total mutates outside the step, replay restarts it at 0.
# 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:
total = 0
receipts: list[str] = []
for item in event["items"]:
context.step(save_item(item), name=f"save-{item['id']}")
total += item["price"]
return {"total": total}
context.step(
save_and_track(item, receipts),
name=f"save-{item['id']}",
)
return {"receipts": receipts}


# Right: each step returns the new running total.
# 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_accumulate(ctx: StepContext, item: dict, running_total: float) -> float:
save_item(item)
return running_total + item["price"]
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:
total = 0.0
receipts: list[str] = []
for item in event["items"]:
total = context.step(
save_and_accumulate(item, total),
receipt_id = context.step(
save_and_return_id(item),
name=f"save-{item['id']}",
)
return {"total": total}
receipts.append(receipt_id)
return {"receipts": receipts}
29 changes: 18 additions & 11 deletions examples/typescript/patterns/determinism/return-value-passing.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,28 @@
// Wrong: total mutates outside the step, replay restarts it at 0.
// 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) => {
let total = 0;
const receipts: string[] = [];
for (const item of event.items) {
await context.step(`save-${item.id}`, async () => saveItem(item));
total += item.price;
await context.step(`save-${item.id}`, async () => {
const receipt = await saveItem(item);
receipts.push(receipt.id);
});
}
return { total };
return { receipts };
});

// Right: each step returns the new running total.
// 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) => {
let total = 0;
const receipts: string[] = [];
for (const item of event.items) {
total = await context.step(`save-${item.id}`, async () => {
await saveItem(item);
return total + item.price;
const receiptId = await context.step(`save-${item.id}`, async () => {
const receipt = await saveItem(item);
return receipt.id;
});
receipts.push(receiptId);
}
return { total };
return { receipts };
});