diff --git a/CHANGELOG.md b/CHANGELOG.md index 876a40f4..c94850b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/composer.json b/composer.json index c010f049..3938475d 100644 --- a/composer.json +++ b/composer.json @@ -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" }, diff --git a/src/V2/Exceptions/ActivityTimeoutException.php b/src/V2/Exceptions/ActivityTimeoutException.php new file mode 100644 index 00000000..bd268dda --- /dev/null +++ b/src/V2/Exceptions/ActivityTimeoutException.php @@ -0,0 +1,12 @@ +forceFill([ diff --git a/src/V2/Support/DefaultWorkflowTaskBridge.php b/src/V2/Support/DefaultWorkflowTaskBridge.php index 1867715c..7f5a7e97 100644 --- a/src/V2/Support/DefaultWorkflowTaskBridge.php +++ b/src/V2/Support/DefaultWorkflowTaskBridge.php @@ -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; @@ -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([ diff --git a/src/V2/Support/LocalActivityExecutor.php b/src/V2/Support/LocalActivityExecutor.php index 8cbb4a45..fb578774 100644 --- a/src/V2/Support/LocalActivityExecutor.php +++ b/src/V2/Support/LocalActivityExecutor.php @@ -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; @@ -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, diff --git a/tests/Fixtures/V2/ReplayRegression/activity-timeout-catch-and-cleanup.json b/tests/Fixtures/V2/ReplayRegression/activity-timeout-catch-and-cleanup.json new file mode 100644 index 00000000..9f1c7b7e --- /dev/null +++ b/tests/Fixtures/V2/ReplayRegression/activity-timeout-catch-and-cleanup.json @@ -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"}] + } +} diff --git a/tests/Fixtures/V2/TestActivityTimeoutCleanupWorkflow.php b/tests/Fixtures/V2/TestActivityTimeoutCleanupWorkflow.php new file mode 100644 index 00000000..f31401bc --- /dev/null +++ b/tests/Fixtures/V2/TestActivityTimeoutCleanupWorkflow.php @@ -0,0 +1,34 @@ +addMinute()); + + return 'too late'; + } +} diff --git a/tests/Unit/V2/ActivityTimeoutReplayTest.php b/tests/Unit/V2/ActivityTimeoutReplayTest.php new file mode 100644 index 00000000..f6c95042 --- /dev/null +++ b/tests/Unit/V2/ActivityTimeoutReplayTest.php @@ -0,0 +1,86 @@ + + */ + 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']); + } +} diff --git a/tests/Unit/V2/FailureFactoryRestoreTest.php b/tests/Unit/V2/FailureFactoryRestoreTest.php index 36eb0ce2..7492fe38 100644 --- a/tests/Unit/V2/FailureFactoryRestoreTest.php +++ b/tests/Unit/V2/FailureFactoryRestoreTest.php @@ -16,6 +16,7 @@ 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; @@ -23,6 +24,23 @@ 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