Skip to content

[Bug]: BatchResult.succeeded() and get_results() drop succeeded items whose result is None #760

Open

Description

@nvasiu

Expected Behavior

BatchResult.succeeded() returns every item whose status is SUCCEEDED, so len(succeeded()) equals success_count.

BatchResult.get_results() returns one entry per succeeded item, in branch order, carrying None for an item whose function returned nothing.

The same holds on the error side. failed() returns every item whose status is FAILED, and get_errors() returns one entry per failed item.

Actual Behavior

Both filter on the item's value as well as its status, so a succeeded item whose function returned None is dropped from both.

Example, ctx.map over four items where two return None:

all_len:        4
all_statuses:   ['SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED', 'SUCCEEDED']
all_results:    [None, 10, None, 30]
success_count:  4
succeeded_len:  2      <- expected 4
get_results:    [10, 30]   <- expected [None, 10, None, 30]

So succeeded() disagrees with success_count on the same batch, and get_results() is no longer index-aligned with the items that produced it. A caller cannot map an output back to its input.

This affects both ctx.map and ctx.parallel.

Note: failed() and get_errors() carry the same value filter, so a failed item with no error would be dropped the same way. But this currently can't be reproduced, see additional context section.

Steps to Reproduce

  1. Write a durable function that calls ctx.map or ctx.parallel over several branches.
  2. Make every branch succeed, at least one branch function should return None, and at least one should return a value.
  3. From the returned BatchResult, compare success_count against len(succeeded()), and compare BatchResult.all against get_results().

Every branch appears in BatchResult.all with status SUCCEEDED, and success_count counts every one of them. But succeeded() and get_results() each omit the branches that returned None, so both are shorter than the number of succeeded items and get_results() is no longer index-aligned with BatchResult.all.

The error-side filters in failed() and get_errors() have no reproduction, see additional context section.

SDK Version

2.0.1

Python Version

3.14

Is this a regression?

No

Last Working Version

No response

Additional Context

Root cause. Four BatchResult accessors add a value check to the status check.

# models.py:495, succeeded()
if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
# models.py:502, failed()
if item.status is BatchItemStatus.FAILED and item.error is not None
# models.py:539, get_results()
if item.status is BatchItemStatus.SUCCEEDED and item.result is not None
# models.py:546, get_errors()
if item.status is BatchItemStatus.FAILED and item.error is not None

None is a legitimate result. A branch function that returns nothing produces BatchItem(status=SUCCEEDED, result=None), which the filter cannot distinguish from "no result", so the item is dropped.

The error side has the identical defect, but it cannot be reproduced today. failed() and get_errors() would drop a FAILED item whose error is None. But no durable function can produce that item. The executor builds a FAILED BatchItem only under a guard requiring an error, and raises InvalidStateError if it's missing.

The other two SDKs (Java and JS) are already unfiltered, so this fix brings Python in line with them.

Activity

  1. yaythomas commented on Oct 7, 2026

    @yaythomas
    Contributor

    Reproduced on current main with the local test runner. The test maps a function over [0, 10, 0, 30]. The function returns None for each 0. The batch result has success_count == 4, len(succeeded()) == 2 and get_results() == [10, 30]. The same numbers appear for parallel, for a map result read back from its checkpoint, and for a map whose items suspend and replay.

    Corrections:

    1. JS has the same success-side filter. JS succeeded() requires item.result !== undefined (batch-result.ts#L128). JS getResults() reads from succeeded(). A JS map or parallel function that returns nothing resolves to undefined. So JS excludes that item from both accessors. JS keeps an item that returns null. Only Java filters on status alone (MapResult.java#L66). The developer guide describes succeeded() as "filtered by status" for both Python and JS. So JS needs the same fix.

    2. Index alignment. get_results() excludes FAILED and STARTED items, before and after the fix. So get_results() matches all by index only when every item succeeded. A regression test can assert len(succeeded()) == success_count and get_results() == [i.result for i in succeeded()]. Today only the first assertion fails.

    3. Replay can build a FAILED item with no error. _create_result builds a FAILED item only when the branch has an error (executor.py#L514). That check applies only to the live path.

      1. The Lambda API makes Error optional on an OperationUpdate (API reference). So a FAILED child-context checkpoint can have no error.
      2. When a serialized batch result is larger than the checkpoint size limit, replay rebuilds each nested item from its child-context checkpoint.
      3. Both rebuild paths copy checkpoint.error unchanged (executor.py#L786, #L839). So the rebuilt item has status FAILED and error None.
      4. failed() and get_errors() exclude that item. throw_if_error() returns without raising, although has_failure is True.

    For the fix:

    • Success side.
      1. BatchItem.result has type R | None, because FAILED and STARTED items have no result.
      2. The is not None check narrows that type to R for mypy. So removing the check from get_results() fails typecheck.
      3. A SUCCEEDED item's result is the return value of a function typed to return R. So cast(R, item.result) is correct for a SUCCEEDED item.
      4. Please remove the check from succeeded(). Then write get_results() as [cast(R, item.result) for item in self.succeeded()]. That keeps the public list[R] return type. A list[R | None] return type would make every caller's type checker treat each result as possibly None.
      5. With this change, mypy reports no errors and the core SDK unit tests pass.
    • Error side.
      1. Child-context replay already handles a FAILED checkpoint with no error. It raises ChildContextError with a generic message (state.py#L328).
      2. JS already stores a generic error on a FAILED item whose checkpoint has no error. JS replay re-runs each finished item through runInChildContext. That call throws ChildContextError("Child context failed") when the checkpoint has no error (run-in-child-context-handler.ts#L233).
      3. Please make both rebuild paths store that generic ChildContextError on the item.
      4. Then every FAILED item has an error, as in JS. So failed() and get_errors() can filter on status alone, with a matching cast. throw_if_error() then raises for that item.
    • Release note. After the fix, get_results() can contain None. So sum(batch.get_results()) raises TypeError when an item returned None. That change is worth a release note.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions