Owning public component
Workflow engine
Exact version or source identity
2.0.7
Minimal reproduction and evidence
Reproduction
A parent workflow that fans out with all() over child():
$calls = [];
for ($i = 0; $i < 4; $i++) {
$calls[] = fn () => child(ChildWorkflow::class, $i);
}
all($calls);
ChildWorkflow runs one short activity and returns.
Start three instances of the parent in the same second. Concurrency between closing children is what matters, not width — this reproduces at four children, and reproduced on every attempt at a wider fan-out.
Environment: 2.0.7, PHP 8.4, MySQL, Redis queue driver, multiple concurrent queue workers.
Analysis
On child closure, WorkflowStub decides whether to wake the parent (src/V2/WorkflowStub.php:4523):
&& ! ParallelChildGroup::shouldWakeParentOnChildClosure(
$parentRun,
$parallelMetadataPath,
$childStatus
)
Three arguments, so lockHistoryForUpdate takes its default (src/V2/Support/ParallelChildGroup.php:261-274):
public static function shouldWakeParentOnChildClosure(
WorkflowRun $parentRun,
array $metadata,
RunStatus $closedChildStatus,
bool $lockHistoryForUpdate = false,
): bool
groupCompletedSuccessfully() then reads the parent's history without the lock, and the flag's own comment describes exactly the case being skipped (src/V2/Support/ParallelChildGroup.php:796-800):
if ($lockHistoryForUpdate) {
// Observe the resolution event committed by the previous holder of
// the parent lock even when this transaction has an older snapshot.
$parentRun->setRelation('historyEvents', $parentRun->historyEvents()->lockForUpdate()->get());
}
So two children closing concurrently can each read a snapshot in which the other's resolution is not yet visible, each conclude the group is incomplete, and neither wakes the parent.
Observed behavior
Three identical parents, each fanning out to four children with all(), started in the same second on 2.0.7:
| run |
status |
ChildWorkflowScheduled |
ChildRunStarted |
ChildRunCompleted |
| A |
waiting |
4 |
4 |
0 |
| B |
completed |
4 |
4 |
4 |
| C |
waiting |
4 |
4 |
0 |
For A and C, externally visible state:
- every child run is
Completed with closed_at set
- the parent has no
ChildRunCompleted history events at all
workflow_run_summaries.liveness_state = waiting_for_child, next_task_id = null
- the parent has no task in
ready or leased, so nothing will ever resume it
php artisan workflow:v2:repair-pass reports
Selected 0 existing task candidate(s) and 0 missing-task run candidate(s)
The parents stay in waiting indefinitely — they were still waiting minutes later, with no further history events. Two of three at width four; reproduced on every attempt at a wider fan-out.
Expected behavior and acceptance criteria
Expected: when the last child of a parallel group closes, exactly one closer observes the completed group and resumes the parent. Concurrency between closers must not allow the group to be seen as incomplete by all of them.
Acceptance criteria:
- A parent that fans out to N children with
all() reaches a terminal status once all N children have closed, regardless of how close together they close.
- The parent records one
ChildRunCompleted (or the matching resolution event) per child.
- A regression test that closes N children concurrently — committing their resolutions from separate transactions — and asserts the parent is resumed exactly once. The completeness check being made under the parent lock is what makes this deterministic.
- Defence in depth: a run in
waiting_for_child whose child links have all closed, and which has no ready/leased task, is selected by TaskRepairCandidates and recovered. Today that state is indistinguishable from a healthy wait, so a lost wake-up is unrecoverable without manual intervention.
Dependencies and related public issues
#427 — added the parallel barrier so a parent would not resume after the first child of a group. This report is the same barrier failing in the opposite direction: with concurrent closers, no child resumes the parent.
Public intake checks
Owning public component
Workflow engine
Exact version or source identity
2.0.7
Minimal reproduction and evidence
Reproduction
A parent workflow that fans out with
all()overchild():ChildWorkflowruns one short activity and returns.Start three instances of the parent in the same second. Concurrency between closing children is what matters, not width — this reproduces at four children, and reproduced on every attempt at a wider fan-out.
Environment: 2.0.7, PHP 8.4, MySQL, Redis queue driver, multiple concurrent queue workers.
Analysis
On child closure,
WorkflowStubdecides whether to wake the parent (src/V2/WorkflowStub.php:4523):Three arguments, so
lockHistoryForUpdatetakes its default (src/V2/Support/ParallelChildGroup.php:261-274):groupCompletedSuccessfully()then reads the parent's history without the lock, and the flag's own comment describes exactly the case being skipped (src/V2/Support/ParallelChildGroup.php:796-800):So two children closing concurrently can each read a snapshot in which the other's resolution is not yet visible, each conclude the group is incomplete, and neither wakes the parent.
Observed behavior
Three identical parents, each fanning out to four children with
all(), started in the same second on 2.0.7:For A and C, externally visible state:
Completedwithclosed_atsetChildRunCompletedhistory events at allworkflow_run_summaries.liveness_state = waiting_for_child,next_task_id = nullreadyorleased, so nothing will ever resume itphp artisan workflow:v2:repair-passreportsSelected 0 existing task candidate(s) and 0 missing-task run candidate(s)The parents stay in
waitingindefinitely — they were still waiting minutes later, with no further history events. Two of three at width four; reproduced on every attempt at a wider fan-out.Expected behavior and acceptance criteria
Expected: when the last child of a parallel group closes, exactly one closer observes the completed group and resumes the parent. Concurrency between closers must not allow the group to be seen as incomplete by all of them.
Acceptance criteria:
all()reaches a terminal status once all N children have closed, regardless of how close together they close.ChildRunCompleted(or the matching resolution event) per child.waiting_for_childwhose child links have all closed, and which has noready/leasedtask, is selected byTaskRepairCandidatesand recovered. Today that state is indistinguishable from a healthy wait, so a lost wake-up is unrecoverable without manual intervention.Dependencies and related public issues
#427 — added the parallel barrier so a parent would not resume after the first child of a group. This report is the same barrier failing in the opposite direction: with concurrent closers, no child resumes the parent.
Public intake checks