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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@

## Unreleased

## 2.0.9 - 2026-09-09

- Terminal activity timeouts restore their recorded exception type during
replay, allowing workflow catch and finally blocks to recover and clean up.
This also covers local activities and existing persisted timeout histories;
applications do not need an exception mapping to resume them.

## 2.0.8 - 2026-09-08

- Concurrent embedded child completions persist their outcomes before checking
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.8",
"product-train": "2.0.9",
"laravel-embedded-upgrade-contract": "resources/laravel-embedded-upgrade-contract.json",
"laravel-dependency-security-policy": "resources/laravel-dependency-security-policy.json"
},
Expand Down
12 changes: 12 additions & 0 deletions src/V2/Exceptions/ActivityTimeoutException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php

declare(strict_types=1);

namespace Workflow\V2\Exceptions;

use RuntimeException;

/** The replayable exception identity recorded for terminal activity deadlines. */
final class ActivityTimeoutException extends RuntimeException
{
}
3 changes: 2 additions & 1 deletion src/V2/Support/ActivityTimeoutEnforcer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Workflow\V2\Enums\RunStatus;
use Workflow\V2\Enums\TaskStatus;
use Workflow\V2\Enums\TaskType;
use Workflow\V2\Exceptions\ActivityTimeoutException;
use Workflow\V2\Models\ActivityAttempt;
use Workflow\V2\Models\ActivityExecution;
use Workflow\V2\Models\WorkflowFailure;
Expand Down Expand Up @@ -337,7 +338,7 @@ private static function recordTerminalTimeout(
): array {
$now = now();
$message = self::timeoutMessage($execution, $timeoutKind);
$exceptionClass = 'Workflow\\V2\\Exceptions\\ActivityTimeoutException';
$exceptionClass = ActivityTimeoutException::class;
$failureCategory = FailureCategory::Timeout;

$execution->forceFill([
Expand Down
3 changes: 2 additions & 1 deletion src/V2/Support/DefaultWorkflowTaskBridge.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use Workflow\V2\Enums\TaskType;
use Workflow\V2\Enums\TimerStatus;
use Workflow\V2\Enums\UpdateStatus;
use Workflow\V2\Exceptions\ActivityTimeoutException;
use Workflow\V2\Exceptions\HistoryEventShapeMismatchException;
use Workflow\V2\Models\ActivityAttempt;
use Workflow\V2\Models\ActivityExecution;
Expand Down Expand Up @@ -3773,7 +3774,7 @@ private function applyRecordLocalActivity(
$exceptionClass = is_string($command['exception_type'] ?? null)
? $command['exception_type']
: ($outcome === 'timed_out'
? 'Workflow\\V2\\Exceptions\\ActivityTimeoutException'
? ActivityTimeoutException::class
: RuntimeException::class);

$failure = WorkflowFailure::query()->create([
Expand Down
3 changes: 2 additions & 1 deletion src/V2/Support/LocalActivityExecutor.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Workflow\V2\Enums\HistoryEventType;
use Workflow\V2\Enums\TaskStatus;
use Workflow\V2\Enums\TaskType;
use Workflow\V2\Exceptions\ActivityTimeoutException;
use Workflow\V2\Exceptions\StructuralLimitExceededException;
use Workflow\V2\Models\ActivityAttempt;
use Workflow\V2\Models\ActivityExecution;
Expand Down Expand Up @@ -714,7 +715,7 @@ private function recordTimeoutOutcome(

$now = now();
$failureCategory = FailureCategory::Timeout;
$exceptionClass = 'Workflow\\V2\\Exceptions\\ActivityTimeoutException';
$exceptionClass = ActivityTimeoutException::class;

$execution->forceFill([
'status' => ActivityStatus::Failed,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
{
"$schema": "https://raw.githubusercontent.com/durable-workflow/.github/main/regression-corpus/evidence-schema.json",
"fixture_schema": "durable-workflow.replay-regression/v1",
"id": "activity-timeout-catch-and-cleanup",
"protocol_version": "1.0",
"bindings": ["php"],
"workflow": {
"type": "Tests\\Fixtures\\V2\\TestActivityTimeoutCleanupWorkflow",
"arguments": [false, true],
"payload_codec": "avro"
},
"history": [
{
"sequence": 1,
"event_type": "WorkflowStarted",
"payload": {},
"recorded_at": "2026-09-09T00:00:00+00:00"
},
{
"sequence": 2,
"event_type": "ActivityTimedOut",
"payload": {
"sequence": 1,
"activity_type": "Tests\\Fixtures\\V2\\TestGreetingActivity",
"exception_class": "Workflow\\V2\\Exceptions\\ActivityTimeoutException",
"message": "Activity schedule-to-close deadline expired.",
"failure_category": "timeout",
"timeout_kind": "schedule_to_close"
},
"recorded_at": "2026-09-09T00:01:00+00:00"
},
{
"sequence": 3,
"event_type": "ActivityCompleted",
"payload": {
"sequence": 2,
"activity_type": "Tests\\Fixtures\\V2\\TestGreetingActivity",
"result": "wwHioz3/VYAiNwoWSGVsbG8sIEFkYSE=",
"payload_codec": "avro"
},
"recorded_at": "2026-09-09T00:01:01+00:00"
}
],
"expected": {
"completed": true,
"result": "recovered",
"commands": [{"type": "complete_workflow"}]
}
}
34 changes: 34 additions & 0 deletions tests/Fixtures/V2/TestActivityTimeoutCleanupWorkflow.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures\V2;

use function Workflow\V2\activity;
use Workflow\V2\Exceptions\ActivityTimeoutException;
use function Workflow\V2\localActivity;
use Workflow\V2\Support\ActivityOptions;
use Workflow\V2\Workflow;

final class TestActivityTimeoutCleanupWorkflow extends Workflow
{
public function handle(bool $local = false, bool $recover = true): string
{
try {
$options = new ActivityOptions(maxAttempts: 1, scheduleToCloseTimeout: 30);
if ($local) {
localActivity(TestClockAdvancingActivity::class, $options);
} else {
activity(TestGreetingActivity::class, $options, 'Ada');
}
} catch (ActivityTimeoutException $failure) {
if (! $recover) {
throw $failure;
}
} finally {
activity(TestGreetingActivity::class, 'Cleanup');
}

return 'recovered';
}
}
18 changes: 18 additions & 0 deletions tests/Fixtures/V2/TestClockAdvancingActivity.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Tests\Fixtures\V2;

use Illuminate\Support\Carbon;
use Workflow\V2\Activity;

final class TestClockAdvancingActivity extends Activity
{
public function handle(): string
{
Carbon::setTestNow(now()->addMinute());

return 'too late';
}
}
86 changes: 86 additions & 0 deletions tests/Unit/V2/ActivityTimeoutReplayTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Tests\Unit\V2;

use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Queue;
use PHPUnit\Framework\Attributes\DataProvider;
use Tests\Fixtures\V2\TestActivityTimeoutCleanupWorkflow;
use Tests\TestCase;
use Workflow\V2\Enums\HistoryEventType;
use Workflow\V2\Enums\TaskStatus;
use Workflow\V2\Enums\TaskType;
use Workflow\V2\Exceptions\ActivityTimeoutException;
use Workflow\V2\Jobs\RunActivityTask;
use Workflow\V2\Jobs\RunWorkflowTask;
use Workflow\V2\Models\WorkflowHistoryEvent;
use Workflow\V2\Models\WorkflowTask;
use Workflow\V2\TaskWatchdog;
use Workflow\V2\WorkflowStub;

final class ActivityTimeoutReplayTest extends TestCase
{
/**
* @return iterable<string, array{bool, bool}>
*/
public static function modes(): iterable
{
yield 'activity caught' => [false, true];
yield 'activity uncaught' => [false, false];
yield 'local activity caught' => [true, true];
yield 'local activity uncaught' => [true, false];
}

#[DataProvider('modes')]
public function testTimeoutReplaysAndRunsCleanup(bool $local, bool $recover): void
{
config([
'queue.default' => 'database',
]);
Queue::fake();
Carbon::setTestNow('2026-09-09 00:00:00');
try {
WorkflowStub::make(TestActivityTimeoutCleanupWorkflow::class, 'timeout-replay')->start($local, $recover);
$this->runTask(TaskType::Workflow);
if (! $local) {
Carbon::setTestNow(now()->addMinute());
$report = TaskWatchdog::runPass();
$this->assertSame(1, $report['activity_timeouts_enforced']);
$this->assertSame([], $report['activity_timeout_failures']);
$this->runTask(TaskType::Workflow);
}

// Reload the persisted event and run a fresh workflow task, not an in-memory throwable.
$event = WorkflowHistoryEvent::where('event_type', HistoryEventType::ActivityTimedOut)->sole();
$this->assertSame(ActivityTimeoutException::class, $event->payload['exception_class']);
$this->assertSame('timeout', $event->payload['failure_category']);
$this->runTask(TaskType::Activity);
$this->runTask(TaskType::Workflow);
$stub = WorkflowStub::load('timeout-replay');
$this->assertSame($recover ? 'completed' : 'failed', $stub->status());
if ($recover) {
$this->assertSame('recovered', $stub->output());
}
$this->assertSame(
1,
WorkflowHistoryEvent::where('event_type', HistoryEventType::ActivityCompleted)->count()
);
$this->assertSame($recover ? 0 : 1, WorkflowTask::where('status', TaskStatus::Failed)->count());
$this->assertSame(
0,
WorkflowTask::where('last_error', 'like', '%Unable to restore workflow failure%')->count()
);
} finally {
Carbon::setTestNow();
}
}

private function runTask(TaskType $type): void
{
$task = WorkflowTask::where('task_type', $type)->where('status', TaskStatus::Ready)->sole();
$job = $type === TaskType::Workflow ? new RunWorkflowTask($task->id) : new RunActivityTask($task->id);
$this->app->call([$job, 'handle']);
}
}
18 changes: 18 additions & 0 deletions tests/Unit/V2/FailureFactoryRestoreTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,31 @@
use Workflow\Serializers\AvroMapValue;
use Workflow\Serializers\Serializer;
use Workflow\V2\Enums\StructuralLimitKind;
use Workflow\V2\Exceptions\ActivityTimeoutException;
use Workflow\V2\Exceptions\RestoredWorkflowException;
use Workflow\V2\Exceptions\StructuralLimitExceededException;
use Workflow\V2\Exceptions\UnresolvedWorkflowFailureException;
use Workflow\V2\Support\FailureFactory;

final class FailureFactoryRestoreTest extends NonDatabaseTestCase
{
public function testRestoresRecordedActivityTimeoutWithoutApplicationMapping(): void
{
$payload = [
'exception_class' => 'Workflow\\V2\\Exceptions\\ActivityTimeoutException',
'message' => 'Activity schedule-to-close deadline expired.',
];
$decoded = Serializer::unserializeWithCodec('avro', Serializer::serializeWithCodec('avro', $payload));
$restored = FailureFactory::restoreForReplay([], $decoded['exception_class'], $decoded['message']);
$this->assertInstanceOf(ActivityTimeoutException::class, $restored);
$this->assertInstanceOf(RuntimeException::class, $restored);
$this->assertSame($payload['message'], $restored->getMessage());
$this->assertInstanceOf(
ActivityTimeoutException::class,
FailureFactory::restoreForReplay(FailureFactory::payload($restored))
);
}

/**
* Regression for #436. PHP's Throwable interface is implemented independently
* by Exception and Error (siblings, not parent/child). The restorer used
Expand Down