bug: Step Functions Map iterations occasionally retry past MaxAttempts or restart the backoff when their first failures coincide #56
amitkastel
started this conversation in
Bugs
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Is there an existing issue for this?
Current Behavior
We run a Step Functions definition in which a
Mapstate's iterations each call a Task that always fails. The Task has thisRetry:Occasionally, one iteration ignores that policy while its sibling follows it. We have two sightings where we captured the per-iteration history, each with a different wrong field. Both came from the same execution shape: 2 items,
MaxConcurrency: 10, and the Task answered bySFN_MOCK_CONFIGwith aThrowfor every invocation. The offsets below are measured from each iteration'sTaskStateEntered.Sighting A: the attempt count and the backoff both reset
The first iteration made 4 attempts, with intervals of 1s, 1s, 2s. Its sibling made 3 attempts at 1s and 2s, as expected.
Sighting B: only the attempt count resets
The first iteration made 4 attempts, with intervals of 1s, 2s, 4s. The backoff progression is correct, but there is one retry past
MaxAttempts. Its sibling again made 3 attempts at 1s and 2s.In both sightings, every
TaskScheduledhas its ownTaskStartedandTaskFailed, and thepreviousEventIdchain of each iteration is clean. These are real extra attempts, not double-recorded events. The two iterations' first failures land within a few milliseconds of each other (in B their event ids are adjacent, #52 and #53).Expected Behavior
Each Map iteration keeps its own retry state, as AWS Step Functions does. With
MaxAttempts: 2every iteration makes exactly 3 attempts, 1s then 2s apart.How are you starting LocalStack?
Custom (please describe below)
Steps To Reproduce
LocalStack runs in Docker via testcontainers (
localstack/localstack:2026.08.0with an auth token;SERVICES=stepfunctions,dynamodb,sts,iam,…) andSFN_MOCK_CONFIGmounted. The execution shape is below. The mocked Task in our definition is anarn:aws:states:::aws-sdk:sfn:startSyncExecutionstate; the resource does not matter, since theThrowcomes from the mock config.{ "StartAt": "Fanout", "States": { "Fanout": { "Type": "Map", "ItemsPath": "$.items", "MaxConcurrency": 10, "ItemProcessor": { "ProcessorConfig": { "Mode": "INLINE" }, "StartAt": "AlwaysFails", "States": { "AlwaysFails": { "Type": "Task", "Resource": "arn:aws:states:::aws-sdk:sfn:describeExecution", "Parameters": { "ExecutionArn": "arn:aws:states:us-east-1:000000000000:execution:x:y" }, "Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 1, "MaxAttempts": 2, "BackoffRate": 2 }], "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "Caught" }], "End": true }, "Caught": { "Type": "Pass", "End": true } } }, "End": true } } }The mock config:
{ "StateMachines": { "MapRetryRepro": { "TestCases": { "AllFail": { "AlwaysFails": "alwaysThrows" } } } }, "MockedResponses": { "alwaysThrows": { "0-9999": { "Throw": { "Error": "Boom", "Cause": "always fails" } } } } }Start executions with input
{"items": [0, 1]}against<stateMachineArn>#AllFail, then count each iteration'sTaskScheduledevents fromGetExecutionHistory.This is intermittent and load-dependent. We have seen it 6 times in CI over three days, where GitHub Actions
ubuntu-latest(4 vCPU) runs 4 test files in parallel against one container. It has never happened on an idle laptop. A standalone script on an idle Apple-silicon machine did not reproduce it: 75 executions (up to 20 items each, 60 of them started at once, about 800 iterations in total) were all correct.Environment
Anything else?
We have a candidate mechanism from reading the source. It is not confirmed: we cannot trigger it on demand.
EvalComponent.heap_key(localstack-core/localstack/services/stepfunctions/asl/component/eval_component.py) is lazily initialised with an unguarded check-then-set:The retrier's counters live in the frame's
env.heapunder that key.MaxAttemptsDecl._eval_bodyreadsf"MaxAttemptsDecl-{self.heap_key}-attempt_number"and stores it with a secondheap_keycall;BackoffRateDecldoes the same fornext_multiplier. Every Map iteration evaluates the same component instances:JobPoolhands everyJobthe samejob_program, and each iteration gets its own frame whoseheapstarts empty.Now suppose two iterations evaluate a retrier's
MaxAttemptsDeclfor the first time concurrently.long_uid()→uuid4()→os.urandom()can release the GIL, so both threads can seeNone:uid1, reads its counter with keyuid1(default-1→0), and stores it underuid1.__heap_keywithuid2.uid2, finds nothing, and the counter restarts at-1, which buys one extra retry.That accounts for both sightings exactly:
MaxAttemptsDeclkey was lost. One extra attempt; the backoff stays 1s → 2s → 4s.MaxAttemptsDeclandBackoffRateDeclkeys were lost. One extra attempt, and the multiplier restarts: 1s → 1s → 2s.It also fits the timing: only the iterations' first failures collide, and the effect shows up on the next failure.
The image we run is the licensed one, whose Step Functions sources are encrypted, so we could not check its copy directly. The community source on
mainhas this pattern, and so does the moto-vendored copy of the interpreter inside that image (moto/stepfunctions/parser/asl/component/eval_component.py). Assigningheap_keyeagerly in__init__, or guarding the lazy init with a lock, would close the window.Environment.next_local_mock_invocation_numberis also shared across frames and incremented without a lock. We don't think it causes this, because it only picks which mocked response is returned and every response here is the sameThrow. It might be worth a look, though.On our side we now tolerate one emulator-added attempt per execution in the affected test and pin the exact
Retrypolicy in a unit test of the definition.All reactions