Skip to content

docs: "Pass data through return values, not closures" example does not demonstrate the failure it warns about #217

Description

@yaythomas

Location

  • Page: docs/patterns/best-practices/determinism.md, section "Pass data through return values, not closures"
  • Examples:
    • examples/python/patterns/determinism/return-value-passing.py
    • examples/typescript/patterns/determinism/return-value-passing.ts
    • examples/java/patterns/determinism/return-value-passing.java

Summary

The section teaches that mutating state outside a step is unsafe because "state outside a step resets to its initial value on replay." The Python and TypeScript examples show a "wrong" implementation that accumulates a running total outside the step, alongside a "right" implementation that returns the running total from each step. However, the "wrong" implementation produces the same total and same return value on replay as the first invocation. The example does not exercise the failure mode the surrounding prose describes.

The Java file only contains the "right" implementation. There is no wrong companion, so the Java tab does not show the anti-pattern at all.

Why the wrong example does not fail

Python wrong version:

total = 0
for item in event["items"]:
    context.step(save_item(item), name=f"save-{item['id']}")
    total += item["price"]
return {"total": total}

On replay:

  1. total = 0 reinitializes.
  2. event["items"] is the handler input, identical across invocations.
  3. context.step(...) returns its cached result (unused here).
  4. total += item["price"] re-executes with the same event-derived value.

Final total equals sum(item["price"] for item in event["items"]) on both first invocation and replay.

TypeScript wrong version has the same shape. total += item.price is a pure function of the handler input.

This matches the page's own rule at the top:

Any code that is not inside a durable operation must be a pure function of the handler inputs and the results of completed operations.

The mutation is a pure function of the handler input, so it satisfies the rule.

Crash-point analysis

Given items = [X, Y, Z] with prices [p, q, r], every possible crash point produces the same replay total:

Crash point Cached steps on replay Replay total
After save-X step save-X p + q + r
After X mutation save-X p + q + r
After save-Y step save-X, save-Y p + q + r
After Y mutation save-X, save-Y p + q + r
After save-Z step all three p + q + r
After Z mutation all three p + q + r

No path yields a different total.

What would make the example illustrative

The mutation needs to depend on something that is not a pure function of the handler input. Concretely, one of:

  • The return value of the step (which the wrong version discards, and cannot reconstruct on replay).
  • A non-deterministic source called outside the step (clock, RNG, external read).

A representative fix: have the step compute an actual amount from the input plus something the step body observes (e.g. an external fee lookup, or a tax computation that must be idempotent under retry). The wrong version discards that amount and adds the raw input price. The right version captures the step's return value in total.

Draft replacement for the Python wrong/right pair:

# Wrong: total is computed from raw input, not from the step's actual result.
@durable_execution
def handler(event, context: DurableContext) -> dict:
    total = 0.0
    for item in event["items"]:
        context.step(charge_customer(item), name=f"charge-{item['id']}")
        total += item["price"]  # ignores what was actually charged
    return {"total": total}


# Right: each step returns the amount actually charged, and total accumulates from it.
@durable_step
def charge_and_report(ctx: StepContext, item: dict) -> float:
    return charge_customer(item)  # returns actual amount, e.g. price + tax


@durable_execution
def handler(event, context: DurableContext) -> dict:
    total = 0.0
    for item in event["items"]:
        total += context.step(charge_and_report(item), name=f"charge-{item['id']}")
    return {"total": total}

With that framing:

  • First invocation: charge_customer computes the tax-inclusive amount for each item. Wrong version accumulates pre-tax prices from the event. Right version accumulates the actual charged amounts.
  • Replay after crash: wrong version's total still equals sum of pre-tax prices (deterministic but wrong business logic). Right version's total correctly rebuilds from cached step results.

This still does not exhibit a replay-specific divergence, but it does show the concrete data loss the section is arguing against. If the section's goal is to demonstrate a replay-only divergence, the wrong version needs to read from a non-deterministic source outside the step.

Additional observations

  1. Section title mismatch. The section title mentions "closures" but neither wrong snippet uses a closure. The TypeScript right version does capture total in an async lambda; that is the only closure in the file. Consider either renaming the section to "Pass data through return values, not local variables" or restructuring the example to actually involve a captured closure variable.

  2. Java file has no wrong example. examples/java/patterns/determinism/return-value-passing.java contains only the right implementation. TypeScript and Python each contain both. Coverage should be consistent.

  3. Python save_item(item) argument evaluation. In context.step(save_item(item), name=...), save_item(item) is evaluated eagerly as a normal Python function call before being passed to context.step. The right version relies on save_and_accumulate being decorated with @durable_step to defer evaluation. The wrong version has no such decorator on save_item, so the side effect fires on every replay regardless of step caching. This is a separate issue from the one the section is teaching against, but readers may be confused by it.

  4. TypeScript right-version closure fragility. total = await context.step(name, async () => { ...; return total + item.price; }) closes over total. This works only because the step name is unique per iteration; if the name were fixed, only the first invocation would execute the lambda and the closure would freeze at total = 0. Worth calling out in prose or comment.

Suggested actions

  • Rework the wrong/right examples to reflect an actual determinism failure, or
  • Move the current example to a section titled "Pass data through step return values" (a design guidance point rather than a determinism failure), and add a separate section that demonstrates a real replay divergence for the determinism page.
  • Add a wrong companion to the Java example.
  • Consider the section title vs closure content mismatch.

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

Status
Done

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions