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 }; });