Repository navigation
[Bug]: BatchResult.succeeded() and get_results() drop succeeded items whose result is None #760
Description
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
- Write a durable function that calls ctx.map or ctx.parallel over several branches.
- Make every branch succeed, at least one branch function should return None, and at least one should return a value.
- 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
yaythomas commentedon Oct 7, 2026
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 hassuccess_count == 4,len(succeeded()) == 2andget_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:
JS has the same success-side filter. JS
succeeded()requiresitem.result !== undefined(batch-result.ts#L128). JSgetResults()reads fromsucceeded(). A JS map or parallel function that returns nothing resolves toundefined. So JS excludes that item from both accessors. JS keeps an item that returnsnull. Only Java filters on status alone (MapResult.java#L66). The developer guide describessucceeded()as "filtered by status" for both Python and JS. So JS needs the same fix.Index alignment.
get_results()excludes FAILED and STARTED items, before and after the fix. Soget_results()matchesallby index only when every item succeeded. A regression test can assertlen(succeeded()) == success_countandget_results() == [i.result for i in succeeded()]. Today only the first assertion fails.Replay can build a FAILED item with no error.
_create_resultbuilds a FAILED item only when the branch has an error (executor.py#L514). That check applies only to the live path.Erroroptional on anOperationUpdate(API reference). So a FAILED child-context checkpoint can have no error.checkpoint.errorunchanged (executor.py#L786, #L839). So the rebuilt item has status FAILED and error None.failed()andget_errors()exclude that item.throw_if_error()returns without raising, althoughhas_failureis True.For the fix:
BatchItem.resulthas typeR | None, because FAILED and STARTED items have no result.is not Nonecheck narrows that type toRfor mypy. So removing the check fromget_results()fails typecheck.R. Socast(R, item.result)is correct for a SUCCEEDED item.succeeded(). Then writeget_results()as[cast(R, item.result) for item in self.succeeded()]. That keeps the publiclist[R]return type. Alist[R | None]return type would make every caller's type checker treat each result as possibly None.ChildContextErrorwith a generic message (state.py#L328).runInChildContext. That call throwsChildContextError("Child context failed")when the checkpoint has no error (run-in-child-context-handler.ts#L233).ChildContextErroron the item.failed()andget_errors()can filter on status alone, with a matchingcast.throw_if_error()then raises for that item.get_results()can contain None. Sosum(batch.get_results())raises TypeError when an item returned None. That change is worth a release note.