diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index 2934dfbb..84c690bd 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -38,6 +38,13 @@ jobs: with: fetch-depth: 10 + - name: Pin Composer to the 2.x line + # The install step below relies on `--no-blocking`. Pin Composer to the + # latest 2.x release so that option is always present and keeps its current + # semantics regardless of the runner's bundled Composer version (a behaviour + # change would require a new Composer major, which this pin excludes). + run: composer self-update --2 + - name: Validate composer.json and composer.lock run: composer validate --strict @@ -51,7 +58,14 @@ jobs: ${{ runner.os }}-php- - name: Install dependencies - run: composer install --prefer-dist --no-progress + # Laravel 10.x is end-of-life and every remaining 10.x release now carries + # published security advisories. The dev test matrix still pins + # orchestra/testbench ^8 (Laravel 10 only), so Composer's default policy + # blocking refuses to install those releases and resolution fails before + # any test runs. --no-blocking lets the install resolve the latest 10.x for + # the test run; composer.json is unchanged and shipped users are unaffected. + # Composer is pinned to 2.x above so this flag is always available. + run: composer install --prefer-dist --no-progress --no-blocking - name: Check coding style via ECS run: vendor/bin/ecs check diff --git a/README.md b/README.md index ebf682e7..ed2ae1bd 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ -
+ Durable Workflow (formerly Laravel Workflow) is a package for the Laravel web framework that provides tools for defining and managing workflows and activities. A workflow is a series of interconnected activities that are executed in a specific order to achieve a desired result. Activities are individual tasks or pieces of logic that are executed as part of a workflow. @@ -73,8 +73,10 @@ $workflow->output(); The Durable Workflow package is sustained by the community via sponsors and volunteers. +- Andriy Karpishyn - Freispace Resource Scheduling - Hugo Cox +- Translate a Book ## Monitoring diff --git a/composer.json b/composer.json index a67f5a68..cf3b21d7 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,11 @@ { - "name": "laravel-workflow/laravel-workflow", + "name": "durable-workflow/workflow", "description": "Durable workflow engine that allows users to write long running persistent distributed workflows (orchestrations) in PHP powered by Laravel queues.", "type": "library", "license": "MIT", + "replace": { + "laravel-workflow/laravel-workflow": "self.version" + }, "autoload": { "psr-4": { "Workflow\\": "src/" diff --git a/src/ChildWorkflow.php b/src/ChildWorkflow.php index 631b3c5a..58d6e6ad 100644 --- a/src/ChildWorkflow.php +++ b/src/ChildWorkflow.php @@ -55,17 +55,19 @@ public function uniqueId() public function handle() { - $workflow = $this->parentWorkflow->toWorkflow(); + if (! $this->parentWorkflow->hasLogByIndex($this->index)) { + $this->parentWorkflow->toWorkflow() + ->next($this->index, $this->now, $this->storedWorkflow->class, $this->return, shouldSignal: false); + } - try { - if ($this->parentWorkflow->hasLogByIndex($this->index)) { + if ($this->shouldWakeParent()) { + $workflow = $this->parentWorkflow->toWorkflow(); + try { $workflow->resume(); - } else { - $workflow->next($this->index, $this->now, $this->storedWorkflow->class, $this->return); - } - } catch (TransitionNotFound) { - if ($workflow->running()) { - $this->release(); + } catch (TransitionNotFound) { + if ($workflow->running()) { + $this->release(); + } } } } @@ -76,4 +78,23 @@ public function middleware() new WithoutOverlappingMiddleware($this->parentWorkflow->id, WithoutOverlappingMiddleware::ACTIVITY, 0, 15), ]; } + + private function shouldWakeParent(): bool + { + $children = $this->parentWorkflow->children() + ->wherePivot('parent_index', '<', StoredWorkflow::ACTIVE_WORKFLOW_INDEX) + ->get(); + + if ($children->isEmpty()) { + return true; + } + + $childIndices = $children->pluck('pivot.parent_index'); + + $logCount = $this->parentWorkflow->logs() + ->whereIn('index', $childIndices) + ->count(); + + return $logCount >= $childIndices->count(); + } } diff --git a/src/Providers/WorkflowServiceProvider.php b/src/Providers/WorkflowServiceProvider.php index 2a050857..a7f6bfd9 100644 --- a/src/Providers/WorkflowServiceProvider.php +++ b/src/Providers/WorkflowServiceProvider.php @@ -29,6 +29,10 @@ public function boot(): void $this->commands([ActivityMakeCommand::class, WorkflowMakeCommand::class]); Event::listen(Looping::class, static function (Looping $event): void { + if (! config('workflows.watchdog.enabled', true)) { + return; + } + Watchdog::wake($event->connectionName, $event->queue); }); } diff --git a/src/Traits/Versions.php b/src/Traits/Versions.php index c9655a7e..820a6eed 100644 --- a/src/Traits/Versions.php +++ b/src/Traits/Versions.php @@ -21,6 +21,16 @@ public static function getVersion( $log = self::$context->storedWorkflow->findLogByIndex(self::$context->index); if ($log) { + // Only treat the recorded log as this change's version marker when its + // class matches. A different class means getVersion() was added to a + // workflow whose history was recorded before the change existed. + // Consuming the slot here would shift every later event, so fall back to + // the minimum supported version and leave the index untouched so the + // original event still replays in its recorded position. + if ($log->class !== 'version:' . $changeId) { + return resolve($minSupported); + } + $version = Serializer::unserialize($log->result); if ($version < $minSupported || $version > $maxSupported) { diff --git a/src/Webhooks.php b/src/Webhooks.php index 92dd595c..70e1ea85 100644 --- a/src/Webhooks.php +++ b/src/Webhooks.php @@ -95,18 +95,19 @@ private static function registerSignalWebhooks($workflow, $basePath) foreach (self::getSignalMethods($workflow) as $method) { if (self::hasWebhookAttributeOnMethod($method)) { $slug = Str::kebab(class_basename($workflow)); - $signal = Str::kebab($method->getName()); + $signalMethod = $method->getName(); + $signal = Str::kebab($signalMethod); Route::post( "{$basePath}/signal/{$slug}/{workflowId}/{$signal}", - static function (Request $request, $workflowId) use ($workflow, $method) { + static function (Request $request, $workflowId) use ($workflow, $signalMethod) { $request = self::validateAuth($request); $workflowInstance = WorkflowStub::load($workflowId); $params = self::resolveNamedParameters( $workflow, - $method->getName(), + $signalMethod, $request->except('workflowId') ); - $workflowInstance->{$method->getName()}(...$params); + $workflowInstance->{$signalMethod}(...$params); return response()->json([ 'message' => 'Signal sent', ]); diff --git a/src/config/workflows.php b/src/config/workflows.php index 26fc15cb..6d3c312d 100644 --- a/src/config/workflows.php +++ b/src/config/workflows.php @@ -21,6 +21,10 @@ 'prune_age' => '1 month', + 'watchdog' => [ + 'enabled' => env('WORKFLOW_WATCHDOG_ENABLED', true), + ], + 'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'), 'webhook_auth' => [ diff --git a/tests/Feature/VersionWorkflowTest.php b/tests/Feature/VersionWorkflowTest.php index 1eb327f8..12956047 100644 --- a/tests/Feature/VersionWorkflowTest.php +++ b/tests/Feature/VersionWorkflowTest.php @@ -4,6 +4,7 @@ namespace Tests\Feature; +use Tests\Fixtures\TestVersionedActivityV1; use Tests\Fixtures\TestVersionedActivityV3; use Tests\Fixtures\TestVersionMinSupportedWorkflow; use Tests\Fixtures\TestVersionWorkflow; @@ -101,6 +102,38 @@ public function testDefaultVersionIsReplayedFromLogs(): void $this->assertSame('v1_result', $output['result1']); } + public function testGetVersionAddedToExistingHistoryDoesNotShiftReplay(): void + { + $workflow = WorkflowStub::make(TestVersionWorkflow::class); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + + // Simulate a workflow whose history was recorded before the getVersion('step-1') + // call was introduced: index 0 holds the first activity's result rather than a + // version marker, so getVersion() must not consume that slot. + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => now(), + 'class' => TestVersionedActivityV1::class, + 'result' => Serializer::serialize('v1_result'), + ]); + + $workflow->start(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + + $output = $workflow->output(); + + // getVersion() falls back to the default version because the change did not + // exist when this history was recorded... + $this->assertSame(WorkflowStub::DEFAULT_VERSION, $output['version1']); + // ...and because the slot was left untouched, the first activity still reads + // its own recorded result instead of a shifted event. + $this->assertSame('v1_result', $output['result1']); + } + public function testVersionBelowMinSupportedThrowsException(): void { $workflow = WorkflowStub::make(TestVersionMinSupportedWorkflow::class); diff --git a/tests/Unit/ChildWorkflowTest.php b/tests/Unit/ChildWorkflowTest.php index 49da05bf..08bfb83c 100644 --- a/tests/Unit/ChildWorkflowTest.php +++ b/tests/Unit/ChildWorkflowTest.php @@ -10,6 +10,7 @@ use Workflow\ChildWorkflow; use Workflow\Models\StoredWorkflow; use Workflow\Serializers\Serializer; +use Workflow\States\WorkflowCreatedStatus; use Workflow\States\WorkflowRunningStatus; use Workflow\WorkflowStub; @@ -36,4 +37,92 @@ public function testHandleReleasesWhenParentWorkflowIsRunning(): void $this->assertSame(1, $storedParent->logs()->count()); $this->assertSame(WorkflowRunningStatus::class, $storedParent->refresh()->status::class); } + + public function testHandleDoesNotWakeParentWhenSiblingsArePending(): void + { + $parent = WorkflowStub::make(TestWorkflow::class); + $storedParent = StoredWorkflow::findOrFail($parent->id()); + $storedParent->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowCreatedStatus::class, + ]); + + $storedChild1 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + $storedChild2 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + $storedChild3 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + + $storedChild1->parents() + ->attach($storedParent, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + $storedChild2->parents() + ->attach($storedParent, [ + 'parent_index' => 1, + 'parent_now' => now(), + ]); + $storedChild3->parents() + ->attach($storedParent, [ + 'parent_index' => 2, + 'parent_now' => now(), + ]); + + // Only the first child completes; two siblings are still pending + $job = new ChildWorkflow(0, now()->toDateTimeString(), $storedChild1, true, $storedParent); + $job->handle(); + + // Log written but parent not dispatched (still in created status) + $this->assertSame(1, $storedParent->logs()->count()); + $this->assertSame(WorkflowCreatedStatus::class, $storedParent->refresh()->status::class); + } + + public function testHandleWakesParentOnLastSiblingCompletion(): void + { + $parent = WorkflowStub::make(TestWorkflow::class); + $storedParent = StoredWorkflow::findOrFail($parent->id()); + $storedParent->update([ + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowCreatedStatus::class, + ]); + + $storedChild1 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + $storedChild2 = StoredWorkflow::create([ + 'class' => TestChildWorkflow::class, + 'arguments' => Serializer::serialize([]), + ]); + + $storedChild1->parents() + ->attach($storedParent, [ + 'parent_index' => 0, + 'parent_now' => now(), + ]); + $storedChild2->parents() + ->attach($storedParent, [ + 'parent_index' => 1, + 'parent_now' => now(), + ]); + + // First child completes — parent should not be woken + $job1 = new ChildWorkflow(0, now()->toDateTimeString(), $storedChild1, true, $storedParent); + $job1->handle(); + $this->assertSame(WorkflowCreatedStatus::class, $storedParent->refresh()->status::class); + + // Second (last) child completes — parent should be dispatched (pending) + $job2 = new ChildWorkflow(1, now()->toDateTimeString(), $storedChild2, true, $storedParent); + $job2->handle(); + $this->assertSame(2, $storedParent->logs()->count()); + $this->assertNotSame(WorkflowCreatedStatus::class, $storedParent->refresh()->status::class); + } } diff --git a/tests/Unit/Config/WorkflowsConfigTest.php b/tests/Unit/Config/WorkflowsConfigTest.php index 184fb5f0..0f550301 100644 --- a/tests/Unit/Config/WorkflowsConfigTest.php +++ b/tests/Unit/Config/WorkflowsConfigTest.php @@ -24,6 +24,9 @@ public function testConfigIsLoaded(): void 'workflow_relationships_table' => 'workflow_relationships', 'serializer' => \Workflow\Serializers\Y::class, 'prune_age' => '1 month', + 'watchdog' => [ + 'enabled' => env('WORKFLOW_WATCHDOG_ENABLED', true), + ], 'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'), ]; diff --git a/tests/Unit/Providers/WorkflowServiceProviderTest.php b/tests/Unit/Providers/WorkflowServiceProviderTest.php index 69d871cd..279ecb69 100644 --- a/tests/Unit/Providers/WorkflowServiceProviderTest.php +++ b/tests/Unit/Providers/WorkflowServiceProviderTest.php @@ -119,6 +119,29 @@ public function testLoopingEventThrottlesWake(): void Queue::assertPushed(Watchdog::class, 1); } + public function testLoopingEventSkipsWhenWatchdogIsDisabled(): void + { + Queue::fake(); + Cache::forget('workflow:watchdog'); + Cache::forget('workflow:watchdog:looping'); + + config([ + 'workflows.watchdog.enabled' => false, + ]); + + StoredWorkflow::create([ + 'class' => TestSimpleWorkflow::class, + 'arguments' => Serializer::serialize([]), + 'status' => WorkflowPendingStatus::$name, + 'updated_at' => now() + ->subSeconds(Watchdog::DEFAULT_TIMEOUT + 1), + ]); + + Event::dispatch(new Looping('redis', 'high,default')); + + Queue::assertNotPushed(Watchdog::class); + } + public function testLoopingEventSkipsWhenThrottleAlreadyHeld(): void { Queue::fake(); diff --git a/tests/Unit/Traits/VersionsTest.php b/tests/Unit/Traits/VersionsTest.php index f3174456..9e1ef1b2 100644 --- a/tests/Unit/Traits/VersionsTest.php +++ b/tests/Unit/Traits/VersionsTest.php @@ -5,6 +5,7 @@ namespace Tests\Unit\Traits; use Mockery; +use Tests\Fixtures\TestVersionedActivityV1; use Tests\Fixtures\TestWorkflow; use Tests\TestCase; use Workflow\Exceptions\VersionNotSupportedException; @@ -55,6 +56,44 @@ public function testLoadsStoredResult(): void $this->assertSame(1, $workflow->logs()->count()); } + public function testReturnsMinSupportedWithoutShiftingIndexForPreVersionHistory(): void + { + $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); + $storedWorkflow = StoredWorkflow::findOrFail($workflow->id()); + $storedWorkflow->logs() + ->create([ + 'index' => 0, + 'now' => WorkflowStub::now(), + 'class' => TestVersionedActivityV1::class, + 'result' => Serializer::serialize('activity_result'), + ]); + $result = null; + + WorkflowStub::setContext([ + 'storedWorkflow' => $storedWorkflow, + 'index' => 0, + 'now' => now(), + 'replaying' => true, + ]); + + WorkflowStub::getVersion('test-change', WorkflowStub::DEFAULT_VERSION, 1) + ->then(static function ($value) use (&$result): void { + $result = $value; + }); + + // A getVersion() call added to a workflow whose history predates it must + // not consume the slot of the first real event: it returns the minimum + // supported version, leaves the index untouched so that event still + // replays in place, and records no version marker. + $this->assertSame(WorkflowStub::DEFAULT_VERSION, $result); + $this->assertSame(0, WorkflowStub::getContext()->index); + $this->assertSame(1, $workflow->logs()->count()); + $this->assertDatabaseMissing('workflow_logs', [ + 'stored_workflow_id' => $workflow->id(), + 'class' => 'version:test-change', + ]); + } + public function testThrowsExceptionWhenVersionBelowMinSupported(): void { $workflow = WorkflowStub::load(WorkflowStub::make(TestWorkflow::class)->id()); diff --git a/tests/Unit/WebhooksTest.php b/tests/Unit/WebhooksTest.php index 9fd3d455..d30c55ad 100644 --- a/tests/Unit/WebhooksTest.php +++ b/tests/Unit/WebhooksTest.php @@ -391,6 +391,26 @@ public function testSignalUnauthorized(): void 'message' => 'Unauthorized', ]); } + + public function testWebhookRoutesCanBeSerializedForRouteCaching() + { + $webhookRoutes = []; + + foreach (Route::getRoutes() as $route) { + if (str_starts_with((string) $route->getName(), 'workflows.')) { + $webhookRoutes[] = $route; + } + } + + $this->assertNotEmpty($webhookRoutes); + + foreach ($webhookRoutes as $route) { + // Mirrors what `php artisan route:cache` does. Closures must only + // capture serializable values (no ReflectionMethod instances). + $route->prepareForSerialization(); + serialize($route); + } + } } class TestClass