From f28459a69efcee0510e9245dea9cac5227dfb8c4 Mon Sep 17 00:00:00 2001 From: Thomas Gaigher Date: Thu, 9 Jul 2026 16:21:03 +0000 Subject: [PATCH] docs: fix determinism return-value-passing example The wrong version of the Pass data through return values example did not demonstrate a replay divergence. The mutation accumulated total from event data, which is a pure function of handler input, so replay reconstructed the same total. The example matched the page's own rule for allowed outside-step code rather than the anti-pattern the prose was teaching. Rewrite the section around the real failure mode. A step body that writes to outer state through a closure loses the write on replay, because replay returns the cached value and skips the body. The new wrong version writes to an outer list from inside the step body and returns void, so replay leaves the list empty. The new right version returns the value from the step and the handler appends to the list outside. - Rewrite the prose and danger admonition to match the concept - Replace TypeScript and Python wrong/right pairs - Add a wrong companion to the Java example, which previously showed only the right implementation Closes #217 --- docs/patterns/best-practices/determinism.md | 18 ++++++--- .../determinism/return-value-passing.java | 32 +++++++++++---- .../determinism/return-value-passing.py | 39 ++++++++++++------- .../determinism/return-value-passing.ts | 29 ++++++++------ 4 files changed, 80 insertions(+), 38 deletions(-) diff --git a/docs/patterns/best-practices/determinism.md b/docs/patterns/best-practices/determinism.md index b7e498d..74442bd 100644 --- a/docs/patterns/best-practices/determinism.md +++ b/docs/patterns/best-practices/determinism.md @@ -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" @@ -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 diff --git a/examples/java/patterns/determinism/return-value-passing.java b/examples/java/patterns/determinism/return-value-passing.java index 2561712..22035c5 100644 --- a/examples/java/patterns/determinism/return-value-passing.java +++ b/examples/java/patterns/determinism/return-value-passing.java @@ -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 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 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); diff --git a/examples/python/patterns/determinism/return-value-passing.py b/examples/python/patterns/determinism/return-value-passing.py index 6eb21ab..4cc8c1e 100644 --- a/examples/python/patterns/determinism/return-value-passing.py +++ b/examples/python/patterns/determinism/return-value-passing.py @@ -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} diff --git a/examples/typescript/patterns/determinism/return-value-passing.ts b/examples/typescript/patterns/determinism/return-value-passing.ts index 7bc60c0..535b235 100644 --- a/examples/typescript/patterns/determinism/return-value-passing.ts +++ b/examples/typescript/patterns/determinism/return-value-passing.ts @@ -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 }; });