Skip to content

ForExecutor race condition: shared TaskContext causes wrong items to be processed in async loops #1354

Description

@ricardozanini

Description

The ForExecutor has a race condition bug when iterating over collections with >2 items and executing async tasks (like emit). The shared TaskContext causes variables to be overwritten before async tasks execute, resulting in wrong items being processed.

Evidence

Observed Behavior

When using forEach to iterate over a collection of 3 items and emit events:

Input:

List.of("lint", "test", "build")

Expected: 3 events emitted for: lint, test, build

Actual: 3 events emitted for: lint, build, build (skips "test", duplicates "build")

Code Analysis

Location: ForExecutor.java lines 82-90

CompletableFuture<WorkflowModel> future = CompletableFuture.completedFuture(taskContext.input());
while (iter.hasNext()) {
    taskContext.variables().put(task.getFor().getEach(), iter.next());  // Line 83: Sets $item
    taskContext.variables().put(task.getFor().getAt(), i++);            // Line 84: Sets $index
    if (whileExpr.map(w -> w.test(workflow, taskContext, taskContext.input())).orElse(true)) {
        future = future.thenCompose(
            input -> TaskExecutorHelper.processTaskList(
                taskExecutor, workflow, Optional.of(taskContext), input));  // Line 90: Uses SAME taskContext
    }
}

Root Cause:

  1. The taskContext object is shared across all loop iterations
  2. Line 83 sets the loop variable ($item) in the shared context
  3. Line 90 schedules an async task that captures a reference to taskContext
  4. The while loop continues and overwrites the variable with the next item before the async task reads it
  5. Result: Async tasks may read the wrong item value (typically the last item in the collection)

Reproduction

Workflow Definition

import static io.serverlessworkflow.fluent.func.dsl.FuncDSL.*;
import java.util.Collection;
import java.util.List;

public Workflow reproducer() {
    return workflow("forEach-bug-reproducer")
        .tasks(
            // Create a collection of 3 items
            function("createCollection", () -> List.of(
                Map.of("id", "item-1", "name", "first"),
                Map.of("id", "item-2", "name", "second"),
                Map.of("id", "item-3", "name", "third")
            )),
            
            // ForEach should emit 3 events, one per item
            forEach(
                (Collection<Map<String, String>> items) -> items,
                emitJson("test.item.emitted", Map.class)
                    .inputFrom("$item")
            )
        )
        .build();
}

Expected Result

3 CloudEvents emitted with data:

  1. {"id": "item-1", "name": "first"}
  2. {"id": "item-2", "name": "second"}
  3. {"id": "item-3", "name": "third"}

Actual Result

3 CloudEvents emitted with data:

  1. {"id": "item-1", "name": "first"} ✓
  2. {"id": "item-3", "name": "third"} ✗ (should be item-2)
  3. {"id": "item-3", "name": "third"} ✗ (duplicate)

Why Existing Tests Don't Catch This

The existing test in ForTaskFunctionRegressionTest.java uses synchronous CallJava operations:

.withDo(List.of(new TaskItem("javaCall",
    new Task().withCallTask(
        new CallTaskJava(CallJava.loopFunction(CallTest::sum, forConfig.getEach()))))))

These complete before the loop advances, so the race condition doesn't manifest. The bug only appears with async tasks like emit, listen, or other operations that schedule work via CompletableFuture.

Suggested Fix

The TaskContext should be cloned or isolated per iteration:

while (iter.hasNext()) {
    Object currentItem = iter.next();
    int currentIndex = i++;
    
    // Create isolated context for this iteration
    TaskContext iterationContext = taskContext.copy(); // or new TaskContext(...)
    iterationContext.variables().put(task.getFor().getEach(), currentItem);
    iterationContext.variables().put(task.getFor().getAt(), currentIndex);
    
    if (whileExpr.map(w -> w.test(workflow, iterationContext, taskContext.input())).orElse(true)) {
        future = future.thenCompose(
            input -> TaskExecutorHelper.processTaskList(
                taskExecutor, workflow, Optional.of(iterationContext), input));
    }
}

Impact

This bug affects any workflow using:

  • forEach with >2 items
  • Async tasks within the loop body (emit, listen, etc.)
  • Results in data corruption, skipped items, and duplicates

Environment

  • SDK Version: Latest main branch
  • Observed in: Quarkus Flow integration with Reactive Messaging (async emit)
  • File: impl/core/src/main/java/io/serverlessworkflow/impl/executors/ForExecutor.java

Activity

  1. self-assigned this
    on Apr 30, 2026
  2. fjtirado commented on Apr 30, 2026

    @fjtirado
    Collaborator

    Im not sure if this a Quarkus flow issue, but definetely not a SDK one.
    Im adding a test to SDK anyway to ensure the implementation is working fine, but if this problem really ocurred in Qflow, it will happen if the completable future of the EventPublished implementation access the item after completing the completable future (that will be a breach of contract and it does not seem Quarkus flow is doing that, so I feel this is a fake issue)

  3. added a commit that references this issue on Apr 30, 2026
    ffd4f36
  4. added a commit that references this issue on Apr 30, 2026
    ecc28e8
  5. ricardozanini commented on May 1, 2026

    @ricardozanini
    CollaboratorAuthor

    Actually, the issue exists - I was able to reproduce it with the test case from PR #1360.

    When you add a bit of lag to the InMemoryEvents#publish method, we can see the issue with ForExecutor passing to CompletableFutures overwritten values. The problem is that the loop completes before the chained lambdas start executing.

    By isolating the for loop variables in the local iteration scope when allocating the task lists for execution, the error went away because the variables allocated in the local scope are no longer rewritten.

  6. added a commit that references this issue on May 5, 2026
    5f1e7df
  7. self-assigned this
    on May 5, 2026
  8. added 4 commits that reference this issue on May 5, 2026
    e752e3c
    36cb209
    c611727
    c67586a
  9. added 2 commits that reference this issue on May 5, 2026
    b064ba9
    19f8c9b
  10. added a commit that references this issue on May 5, 2026
    bb7586b
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

No labels
No labels

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions