Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

## Unreleased

## 2.0.8 - 2026-09-08

- Concurrent embedded child completions persist their outcomes before checking
the parallel barrier, preventing a lost parent wake-up under MySQL repeatable
reads. Existing repair paths recover stranded parents from durable child
terminal history without rerunning completed children.
- A child that handles an activity failure and completes no longer propagates
historical failure metadata as its result. Genuine terminal failures retain
their own failure identity across embedded and service-mode completion paths.
- Parent-notification errors roll back the workflow task for repair instead of
appending a workflow failure after completion. This does not rewrite existing
histories that already contain contradictory terminal events.

## 2.0.7 - 2026-09-08

- Activity metadata projections no longer download or decode external arguments
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@
"dev-main": "2.0.x-dev"
},
"durable-workflow": {
"product-train": "2.0.7",
"product-train": "2.0.8",
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
},
Expand Down
14 changes: 14 additions & 0 deletions src/V2/Support/ChildRunHistory.php
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,20 @@ public static function resolvedStatus(
};
}

public static function terminalFailureForRun(WorkflowRun $childRun): ?WorkflowFailure
{
if (self::resolvedStatus(null, $childRun) === RunStatus::Completed) {
return null;
}

$failureId = self::terminalEventForRun($childRun)?->payload['failure_id'] ?? null;

return $childRun->failures->first(
static fn (WorkflowFailure $failure): bool => ! $failure->handled
&& ($failureId === null || $failure->id === $failureId)
);
}

public static function outputForResolution(
WorkflowHistoryEvent $resolutionEvent,
?WorkflowRun $childRun = null,
Expand Down
2 changes: 1 addition & 1 deletion src/V2/Support/DefaultWorkflowTaskBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -4672,7 +4672,7 @@ private function recordChildResolution(
)
->sortByDesc('sequence')
->first();
$failure = $childRun->failures->first();
$failure = ChildRunHistory::terminalFailureForRun($childRun);
$parallelMetadataPath = ChildRunHistory::parallelGroupPathForSequence($run, $sequence);
$parallelMetadata = ParallelChildGroup::payloadForPath($parallelMetadataPath);
$childOutput = $childTerminalEvent?->event_type === HistoryEventType::WorkflowCompleted
Expand Down
27 changes: 26 additions & 1 deletion src/V2/Support/TaskRepairCandidates.php
Original file line number Diff line number Diff line change
Expand Up @@ -643,7 +643,32 @@ private static function missingTaskRunQuery(
?string $queue = null,
) {
$query = WorkflowRunSummary::query()
->where('liveness_state', 'repair_needed')
->where(static function ($candidate): void {
$candidate->where('liveness_state', 'repair_needed')
->orWhere(static function ($waiting): void {
$waiting->where('liveness_state', 'waiting_for_child')
->whereDoesntHave('run.tasks', static function ($task): void {
$task->where('task_type', 'workflow')
->whereIn('status', [TaskStatus::Ready->value, TaskStatus::Leased->value]);
})
->whereHas('run.childLinks', static function ($link): void {
$link->where('link_type', 'child_workflow');
})
->whereDoesntHave('run.childLinks', static function ($link): void {
$link->where('link_type', 'child_workflow')
->where(static function ($unclosed): void {
$unclosed->whereDoesntHave('childRun')
->orWhereHas('childRun', static function ($child): void {
$child->whereIn('status', [
RunStatus::Pending->value,
RunStatus::Running->value,
RunStatus::Waiting->value,
]);
});
});
});
});
})
->whereNull('next_task_id')
->whereIn('status', [RunStatus::Pending->value, RunStatus::Running->value, RunStatus::Waiting->value]);

Expand Down
126 changes: 91 additions & 35 deletions src/V2/Support/WorkflowExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -2007,6 +2007,54 @@ public function timeoutIfDeadlineExpired(WorkflowRun $run, WorkflowTask $task):
return true;
}

/**
* The caller holds the parent run lock; child terminal history, not row status, authorizes recovery.
*/
public function recoverClosedChildResolutions(WorkflowRun $parentRun): void
{
if ($parentRun->status->isTerminal() || $parentRun->tasks()
->where('task_type', TaskType::Workflow->value)
->whereIn('status', [TaskStatus::Ready->value, TaskStatus::Leased->value])
->lockForUpdate()
->exists()) {
return;
}

$parentRun->setRelation('historyEvents', $parentRun->historyEvents()->lockForUpdate()->get());
foreach (ChildRunHistory::knownSequences($parentRun) as $sequence) {
if (ChildRunHistory::resolutionEventForSequence($parentRun, $sequence) !== null) {
continue;
}
try {
WorkflowStepHistory::assertCompatible($parentRun, $sequence, WorkflowStepHistory::CHILD_WORKFLOW);
WorkflowStepHistory::assertTypedHistoryRecorded(
$parentRun,
$sequence,
WorkflowStepHistory::CHILD_WORKFLOW
);
} catch (HistoryEventShapeMismatchException) {
continue;
}

$childRun = ChildRunHistory::childRunForSequence($parentRun, $sequence);
if (! $childRun instanceof WorkflowRun || ! $childRun->historyEvents->contains(
static fn (WorkflowHistoryEvent $event): bool => in_array($event->event_type, [
HistoryEventType::WorkflowCompleted,
HistoryEventType::WorkflowFailed,
HistoryEventType::WorkflowCancelled,
HistoryEventType::WorkflowTerminated,
], true)
)) {
continue;
}
if ($this->startChildRetryIfAvailable($parentRun, $sequence, $childRun) === null) {
$this->recordChildResolution($parentRun, null, $sequence, $childRun);
}
$parentRun->unsetRelation('childLinks');
$parentRun->setRelation('historyEvents', $parentRun->historyEvents()->lockForUpdate()->get());
}
}

private function scheduleActivity(
WorkflowRun $run,
WorkflowTask $task,
Expand Down Expand Up @@ -2920,7 +2968,7 @@ private function recordChildResolution(
)
->sortByDesc('sequence')
->first();
$failure = $childRun->failures->first();
$failure = ChildRunHistory::terminalFailureForRun($childRun);
$parallelMetadataPath = ChildRunHistory::parallelGroupPathForSequence($run, $sequence);
$parallelMetadata = ParallelChildGroup::payloadForPath($parallelMetadataPath);
$childOutput = $childTerminalEvent?->event_type === HistoryEventType::WorkflowCompleted
Expand Down Expand Up @@ -3718,6 +3766,12 @@ private function failRun(
string $sourceKind,
string $sourceId,
): void {
if ($run->status === RunStatus::Completed && $run->historyEvents()
->where('event_type', HistoryEventType::WorkflowCompleted->value)->exists()) {
// Completion side effects failed, not workflow code. Roll back this task attempt.
throw $throwable;
}

if ($throwable instanceof UnresolvedWorkflowFailureException) {
$this->blockReplayUntilFailureCanBeRestored($run, $task, $throwable);

Expand Down Expand Up @@ -4353,21 +4407,18 @@ private function dispatchParentResumeTasks(WorkflowRun $childRun): void
continue;
}

$parallelMetadataPath = ChildRunHistory::parallelGroupPathForSequence(
$parentRun,
$parentReference['parent_sequence'],
);
$childStatus = ChildRunHistory::resolvedStatus(null, $childRun);

if (
$parallelMetadataPath !== []
&& $childStatus instanceof RunStatus
&& ! ParallelChildGroup::shouldWakeParentOnChildClosure(
try {
WorkflowStepHistory::assertCompatible(
$parentRun,
$parallelMetadataPath,
$childStatus
)
) {
$parentReference['parent_sequence'],
WorkflowStepHistory::CHILD_WORKFLOW,
);
WorkflowStepHistory::assertTypedHistoryRecorded(
$parentRun,
$parentReference['parent_sequence'],
WorkflowStepHistory::CHILD_WORKFLOW,
);
} catch (HistoryEventShapeMismatchException) {
$this->projectRun(
$parentRun->fresh([
'instance',
Expand All @@ -4385,12 +4436,29 @@ private function dispatchParentResumeTasks(WorkflowRun $childRun): void
continue;
}

// Every closer persists its outcome before checking the shared barrier.
// The next parent-lock holder must not depend on a stale child snapshot.
$resolutionEvent = $this->recordChildResolution(
$parentRun,
null,
$parentReference['parent_sequence'],
$childRun,
);
$parentTaskPayload = WorkflowTaskPayload::forChildResolution($resolutionEvent);
$parallelMetadataPath = ChildRunHistory::parallelGroupPathForSequence(
$parentRun,
$parentReference['parent_sequence'],
);
$childStatus = ChildRunHistory::resolvedStatus($resolutionEvent, $childRun);

if (
$parallelMetadataPath !== []
&& ! $this->recordClosedParallelChildResolutions(
&& $childStatus instanceof RunStatus
&& ! ParallelChildGroup::shouldWakeParentOnChildClosure(
$parentRun,
$parallelMetadataPath,
$parentReference['parent_sequence'],
$childStatus,
lockHistoryForUpdate: true,
)
) {
$this->projectRun(
Expand All @@ -4410,18 +4478,14 @@ private function dispatchParentResumeTasks(WorkflowRun $childRun): void
continue;
}

try {
WorkflowStepHistory::assertCompatible(
$parentRun,
$parentReference['parent_sequence'],
WorkflowStepHistory::CHILD_WORKFLOW,
);
WorkflowStepHistory::assertTypedHistoryRecorded(
if (
$parallelMetadataPath !== []
&& ! $this->recordClosedParallelChildResolutions(
$parentRun,
$parallelMetadataPath,
$parentReference['parent_sequence'],
WorkflowStepHistory::CHILD_WORKFLOW,
);
} catch (HistoryEventShapeMismatchException) {
)
) {
$this->projectRun(
$parentRun->fresh([
'instance',
Expand All @@ -4438,14 +4502,6 @@ private function dispatchParentResumeTasks(WorkflowRun $childRun): void

continue;
}

$resolutionEvent = $this->recordChildResolution(
$parentRun,
null,
$parentReference['parent_sequence'],
$childRun,
);
$parentTaskPayload = WorkflowTaskPayload::forChildResolution($resolutionEvent);
}

$hasOpenWorkflowTask = WorkflowTask::query()
Expand Down
2 changes: 2 additions & 0 deletions src/V2/TaskWatchdog.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
use Workflow\V2\Support\TaskRepairCandidates;
use Workflow\V2\Support\TaskRepairPolicy;
use Workflow\V2\Support\WorkerCompatibilityFleet;
use Workflow\V2\Support\WorkflowExecutor;

/**
* Periodic repair loop that scans for stuck workflow tasks, expired activity
Expand Down Expand Up @@ -286,6 +287,7 @@ private static function recoverMissingTask(string $runId): array
->lockForUpdate()
->findOrFail($runId);

app(WorkflowExecutor::class)->recoverClosedChildResolutions($run);
$summary = self::historyProjectionRole()->projectRun($run);

if ($summary->liveness_state !== 'repair_needed' || $summary->next_task_id !== null) {
Expand Down
6 changes: 4 additions & 2 deletions src/V2/WorkflowStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -1598,6 +1598,7 @@ public function attemptRepair(): CommandResult
->get()
);

app(\Workflow\V2\Support\WorkflowExecutor::class)->recoverClosedChildResolutions($run);
$summary = self::projectRun($run);

if (in_array($summary->liveness_state, ['repair_needed', 'workflow_replay_blocked'], true)) {
Expand Down Expand Up @@ -4523,7 +4524,8 @@ private function createParentResumeTasks(WorkflowRun $childRun): array
&& ! ParallelChildGroup::shouldWakeParentOnChildClosure(
$parentRun,
$parallelMetadataPath,
$childStatus
$childStatus,
lockHistoryForUpdate: true,
)
) {
self::projectRun($parentRun, self::PROJECTION_RUN_RELATIONS_WITH_CHILDREN);
Expand Down Expand Up @@ -4604,7 +4606,7 @@ private function recordParentChildResolution(
)
->sortByDesc('sequence')
->first();
$failure = $childRun->failures->first();
$failure = ChildRunHistory::terminalFailureForRun($childRun);
$parallelMetadataPath = ChildRunHistory::parallelGroupPathForSequence($parentRun, $sequence);
$parallelMetadata = ParallelChildGroup::payloadForPath($parallelMetadataPath);
$childOutput = $childTerminalEvent?->event_type === HistoryEventType::WorkflowCompleted
Expand Down
Loading