From 87ce7616de6da38f2caac4083bb3c404b380043c Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 18:23:11 -0500 Subject: [PATCH 1/8] Add signals --- composer.json | 3 +- src/Activity.php | 1 + src/Models/StoredWorkflow.php | 5 ++ src/Models/StoredWorkflowSignal.php | 17 +++++ src/SignalMethod.php | 9 +++ src/Workflow.php | 66 ++++++++++++++++--- src/WorkflowStub.php | 50 +++++++++++++- ...1_000002_create_workflow_signals_table.php | 38 +++++++++++ tests/Feature/WorkflowTest.php | 20 ++++++ tests/TestWorkflow.php | 20 +++++- 10 files changed, 217 insertions(+), 12 deletions(-) create mode 100644 src/Models/StoredWorkflowSignal.php create mode 100644 src/SignalMethod.php create mode 100644 src/migrations/2022_01_01_000002_create_workflow_signals_table.php diff --git a/composer.json b/composer.json index c1660df..889cedc 100644 --- a/composer.json +++ b/composer.json @@ -23,7 +23,8 @@ } ], "require": { - "spatie/laravel-model-states": "^2.1" + "spatie/laravel-model-states": "^2.1", + "react/promise": "^2.9" }, "require-dev": { "orchestra/testbench": "^7.1" diff --git a/src/Activity.php b/src/Activity.php index 5077c60..78647c8 100644 --- a/src/Activity.php +++ b/src/Activity.php @@ -5,6 +5,7 @@ use BadMethodCallException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; diff --git a/src/Models/StoredWorkflow.php b/src/Models/StoredWorkflow.php index 8551360..6891def 100644 --- a/src/Models/StoredWorkflow.php +++ b/src/Models/StoredWorkflow.php @@ -28,4 +28,9 @@ public function logs() { return $this->hasMany(StoredWorkflowLog::class); } + + public function signals() + { + return $this->hasMany(StoredWorkflowSignal::class); + } } diff --git a/src/Models/StoredWorkflowSignal.php b/src/Models/StoredWorkflowSignal.php new file mode 100644 index 0000000..a112fcd --- /dev/null +++ b/src/Models/StoredWorkflowSignal.php @@ -0,0 +1,17 @@ +belongsTo(StoredWorkflow::class); + } +} diff --git a/src/SignalMethod.php b/src/SignalMethod.php new file mode 100644 index 0000000..bf6a265 --- /dev/null +++ b/src/SignalMethod.php @@ -0,0 +1,9 @@ +coroutine = $this->execute(...$this->arguments); while ($this->coroutine->valid()) { + $log = $this->model->logs()->whereIndex($this->index)->first(); + + if ($log) { + $previousLog = $this->model->logs()->whereIndex($this->index - 1)->first(); + + $this->model + ->signals() + ->where('created_at', '<=', $log->created_at) + ->when($previousLog, function($query, $previousLog) { + $query->where('created_at', '>', $previousLog); + }) + ->each(function ($signal) { + $this->{$signal->method}(...unserialize($signal->arguments)); + }); + } + $index = $this->index++; $current = $this->coroutine->current(); - $log = $this->model->logs()->whereIndex($index)->first(); + if ($current instanceof PromiseInterface) { + $resolved = false; - if ($log) { - $this->coroutine->send(unserialize($log->result)); + $current->then(function ($value) use ($index, &$resolved) { + $resolved = true; + + $this->model->logs()->create([ + 'index' => $index, + 'result' => serialize(true), + ]); + + $log = $this->model->logs()->whereIndex($index)->first(); + + $this->coroutine->send(unserialize($log->result)); + }); + + if (!$resolved) { + $this->model->status->transitionTo(WorkflowWaitingStatus::class); + + return; + } } else { - $this->model->status->transitionTo(WorkflowWaitingStatus::class); + $log = $this->model->logs()->whereIndex($index)->first(); + + if ($log) { + $nextLog = $this->model->logs()->whereIndex($index + 1)->first(); + + $this->model + ->signals() + ->where('created_at', '>=', $log->created_at) + ->when($nextLog, function($query, $nextLog) { + $query->where('created_at', '<', $nextLog); + }) + ->each(function ($signal) { + $this->{$signal->method}(...unserialize($signal->arguments)); + }); + + $this->coroutine->send(unserialize($log->result)); + } else { + $this->model->status->transitionTo(WorkflowWaitingStatus::class); - $current->activity()::dispatch($index, $this->model, ...$current->arguments()); + $current->activity()::dispatch($index, $this->model, ...$current->arguments()); - return; + return; + } } } diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 7147076..9f5e95c 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -2,15 +2,19 @@ namespace Workflow; +use React\Promise\Deferred; +use React\Promise\PromiseInterface; +use ReflectionClass; use Workflow\Models\StoredWorkflow; - use Workflow\States\WorkflowCompletedStatus; use Workflow\States\WorkflowFailedStatus; use Workflow\States\WorkflowPendingStatus; +use function React\Promise\resolve; class WorkflowStub { protected $model; + protected $await; private function __construct($model) { @@ -36,6 +40,19 @@ public static function fromStoredWorkflow(StoredWorkflow $model) return new static($model); } + public static function await($condition): PromiseInterface + { + $result = $condition(); + + if ($result === true) { + return resolve(true); + } + + $deferred = new Deferred(); + + return $deferred->promise(); + } + public function id() { return $this->model->id; @@ -48,7 +65,7 @@ public function output() public function running() { - return ! in_array($this->status(), [ + return !in_array($this->status(), [ WorkflowCompletedStatus::class, WorkflowFailedStatus::class, ]); @@ -110,4 +127,33 @@ private function dispatch() $this->model->class::dispatch($this->model, ...unserialize($this->model->arguments)); } + + public function __call($method, $arguments) + { + if (collect((new ReflectionClass($this->model->class))->getMethods()) + ->filter(function ($method) { + return collect($method->getAttributes()) + ->contains(function ($attribute) { + return $attribute->getName() === SignalMethod::class; + }); + }) + ->map(function ($method) { + return $method->getName(); + })->contains($method) + ) { + $this->model->signals()->create([ + 'method' => $method, + 'arguments' => serialize($arguments), + ]); + + while (true) { + try { + $this->fresh()->dispatch(); + break; + } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $th) { + usleep(1000); + } + } + } + } } diff --git a/src/migrations/2022_01_01_000002_create_workflow_signals_table.php b/src/migrations/2022_01_01_000002_create_workflow_signals_table.php new file mode 100644 index 0000000..da0eae2 --- /dev/null +++ b/src/migrations/2022_01_01_000002_create_workflow_signals_table.php @@ -0,0 +1,38 @@ +id('id'); + $table->foreignId('stored_workflow_id')->index(); + $table->text('method'); + $table->text('arguments')->nullable(); + $table->timestamps(); + + $table->index(['stored_workflow_id', 'created_at']); + + $table->foreign('stored_workflow_id')->references('id')->on('workflows')->onDelete('cascade'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('workflow_signals'); + } +} diff --git a/tests/Feature/WorkflowTest.php b/tests/Feature/WorkflowTest.php index 68e28ef..35e2c1e 100644 --- a/tests/Feature/WorkflowTest.php +++ b/tests/Feature/WorkflowTest.php @@ -18,6 +18,24 @@ public function testCompleted() $workflow->start(); + $workflow->cancel(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame('workflow_activity_other', $workflow->output()); + } + + public function testCompletedDelay() + { + $workflow = WorkflowStub::make(TestWorkflow::class); + + $workflow->start(false, true); + + sleep(5); + + $workflow->cancel(); + while ($workflow->running()); $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); @@ -49,6 +67,8 @@ public function testRecoveredFailed() $workflow->fresh()->start(); + $workflow->cancel(); + while ($workflow->running()); $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); diff --git a/tests/TestWorkflow.php b/tests/TestWorkflow.php index c53494a..618844b 100644 --- a/tests/TestWorkflow.php +++ b/tests/TestWorkflow.php @@ -3,20 +3,38 @@ namespace Tests; use Workflow\ActivityStub; +use Workflow\SignalMethod; use Workflow\Workflow; +use Workflow\WorkflowStub; class TestWorkflow extends Workflow { - public function execute($shouldFail = false) + private bool $canceled; + + #[SignalMethod] + public function cancel() + { + $this->canceled = true; + } + + public function execute($shouldFail = false, $shouldAssert = false) { + $this->canceled = false; + $otherResult = yield ActivityStub::make(TestOtherActivity::class, 'other'); + if ($shouldAssert) { + assert($this->canceled === false); + } + if ($shouldFail) { $result = yield ActivityStub::make(TestFailingActivity::class); } else { $result = yield ActivityStub::make(TestActivity::class); } + yield WorkflowStub::await(fn () => $this->canceled); + return 'workflow_' . $result . '_' . $otherResult; } } From 1a8512b277310662436efd5fd1f845c667f31801 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 19:01:42 -0500 Subject: [PATCH 2/8] Update test --- tests/TestWorkflow.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/TestWorkflow.php b/tests/TestWorkflow.php index 618844b..250631c 100644 --- a/tests/TestWorkflow.php +++ b/tests/TestWorkflow.php @@ -9,7 +9,7 @@ class TestWorkflow extends Workflow { - private bool $canceled; + private bool $canceled = false; #[SignalMethod] public function cancel() @@ -19,8 +19,6 @@ public function cancel() public function execute($shouldFail = false, $shouldAssert = false) { - $this->canceled = false; - $otherResult = yield ActivityStub::make(TestOtherActivity::class, 'other'); if ($shouldAssert) { From 876d03b4b3bc3d0a7231dc09feb1ab100df7c829 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 20:25:52 -0500 Subject: [PATCH 3/8] More tests --- src/Workflow.php | 66 +++++++++++++++++----------------- src/WorkflowStub.php | 2 +- tests/Feature/WorkflowTest.php | 33 +++++++++++++++-- tests/TestSimpleWorkflow.php | 25 +++++++++++++ tests/TestWorkflow.php | 6 ++-- 5 files changed, 93 insertions(+), 39 deletions(-) create mode 100644 tests/TestSimpleWorkflow.php diff --git a/src/Workflow.php b/src/Workflow.php index 0ee83af..f09d03d 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -49,41 +49,49 @@ public function handle() { $this->model->status->transitionTo(WorkflowRunningStatus::class); + $log = $this->model->logs()->whereIndex($this->index)->first(); + + $this->model + ->signals() + ->when($log, function($query, $log) { + $query->where('created_at', '<=', $log->created_at); + }) + ->each(function ($signal) { + $this->{$signal->method}(...unserialize($signal->arguments)); + }); + $this->coroutine = $this->execute(...$this->arguments); while ($this->coroutine->valid()) { - $log = $this->model->logs()->whereIndex($this->index)->first(); - - if ($log) { - $previousLog = $this->model->logs()->whereIndex($this->index - 1)->first(); - - $this->model - ->signals() - ->where('created_at', '<=', $log->created_at) - ->when($previousLog, function($query, $previousLog) { - $query->where('created_at', '>', $previousLog); - }) - ->each(function ($signal) { - $this->{$signal->method}(...unserialize($signal->arguments)); - }); - } - - $index = $this->index++; + $previousLog = $log; + $nextLog = $this->model->logs()->whereIndex($this->index + 1)->first(); + + $this->model + ->signals() + ->when($nextLog, function($query, $nextLog) { + $query->where('created_at', '<=', $nextLog->created_at); + }) + ->when($previousLog, function($query, $previousLog) { + $query->where('created_at', '>', $previousLog->created_at); + }) + ->each(function ($signal) { + $this->{$signal->method}(...unserialize($signal->arguments)); + }); $current = $this->coroutine->current(); if ($current instanceof PromiseInterface) { $resolved = false; - $current->then(function ($value) use ($index, &$resolved) { + $current->then(function ($value) use (&$resolved) { $resolved = true; $this->model->logs()->create([ - 'index' => $index, + 'index' => $this->index, 'result' => serialize(true), ]); - $log = $this->model->logs()->whereIndex($index)->first(); + $log = $this->model->logs()->whereIndex($this->index)->first(); $this->coroutine->send(unserialize($log->result)); }); @@ -94,30 +102,20 @@ public function handle() return; } } else { - $log = $this->model->logs()->whereIndex($index)->first(); + $log = $this->model->logs()->whereIndex($this->index)->first(); if ($log) { - $nextLog = $this->model->logs()->whereIndex($index + 1)->first(); - - $this->model - ->signals() - ->where('created_at', '>=', $log->created_at) - ->when($nextLog, function($query, $nextLog) { - $query->where('created_at', '<', $nextLog); - }) - ->each(function ($signal) { - $this->{$signal->method}(...unserialize($signal->arguments)); - }); - $this->coroutine->send(unserialize($log->result)); } else { $this->model->status->transitionTo(WorkflowWaitingStatus::class); - $current->activity()::dispatch($index, $this->model, ...$current->arguments()); + $current->activity()::dispatch($this->index, $this->model, ...$current->arguments()); return; } } + + $this->index++; } $this->model->output = serialize($this->coroutine->getReturn()); diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 9f5e95c..265ff76 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -146,7 +146,7 @@ public function __call($method, $arguments) 'arguments' => serialize($arguments), ]); - while (true) { + while ($this->running()) { try { $this->fresh()->dispatch(); break; diff --git a/tests/Feature/WorkflowTest.php b/tests/Feature/WorkflowTest.php index 35e2c1e..7093f57 100644 --- a/tests/Feature/WorkflowTest.php +++ b/tests/Feature/WorkflowTest.php @@ -3,15 +3,44 @@ namespace Tests\Feature; use Tests\TestCase; - +use Tests\TestSimpleWorkflow; use Tests\TestWorkflow; -use Workflow\Exceptions\WorkflowFailedException; use Workflow\States\WorkflowCompletedStatus; use Workflow\States\WorkflowFailedStatus; use Workflow\WorkflowStub; class WorkflowTest extends TestCase { + public function testSimple() + { + $workflow = WorkflowStub::make(TestSimpleWorkflow::class); + + $workflow->start(); + + $workflow->cancel(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame('workflow', $workflow->output()); + } + + public function testSimpleDelay() + { + $workflow = WorkflowStub::make(TestSimpleWorkflow::class); + + $workflow->start(); + + sleep(5); + + $workflow->cancel(); + + while ($workflow->running()); + + $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); + $this->assertSame('workflow', $workflow->output()); + } + public function testCompleted() { $workflow = WorkflowStub::make(TestWorkflow::class); diff --git a/tests/TestSimpleWorkflow.php b/tests/TestSimpleWorkflow.php new file mode 100644 index 0000000..8617c96 --- /dev/null +++ b/tests/TestSimpleWorkflow.php @@ -0,0 +1,25 @@ +canceled = true; + } + + public function execute() + { + yield WorkflowStub::await(fn () => $this->canceled); + + return 'workflow'; + } +} diff --git a/tests/TestWorkflow.php b/tests/TestWorkflow.php index 250631c..3bbbde7 100644 --- a/tests/TestWorkflow.php +++ b/tests/TestWorkflow.php @@ -19,11 +19,13 @@ public function cancel() public function execute($shouldFail = false, $shouldAssert = false) { + if ($shouldAssert) + assert($this->canceled === false); + $otherResult = yield ActivityStub::make(TestOtherActivity::class, 'other'); - if ($shouldAssert) { + if ($shouldAssert) assert($this->canceled === false); - } if ($shouldFail) { $result = yield ActivityStub::make(TestFailingActivity::class); From 619dee910330e674f3603fb67f8d7d4fd3641e97 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 20:49:38 -0500 Subject: [PATCH 4/8] Cleanup --- src/Activity.php | 1 - src/Workflow.php | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/Activity.php b/src/Activity.php index 78647c8..5077c60 100644 --- a/src/Activity.php +++ b/src/Activity.php @@ -5,7 +5,6 @@ use BadMethodCallException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; -use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; diff --git a/src/Workflow.php b/src/Workflow.php index f09d03d..eb10114 100644 --- a/src/Workflow.php +++ b/src/Workflow.php @@ -63,7 +63,6 @@ public function handle() $this->coroutine = $this->execute(...$this->arguments); while ($this->coroutine->valid()) { - $previousLog = $log; $nextLog = $this->model->logs()->whereIndex($this->index + 1)->first(); $this->model @@ -71,8 +70,8 @@ public function handle() ->when($nextLog, function($query, $nextLog) { $query->where('created_at', '<=', $nextLog->created_at); }) - ->when($previousLog, function($query, $previousLog) { - $query->where('created_at', '>', $previousLog->created_at); + ->when($log, function($query, $log) { + $query->where('created_at', '>', $log->created_at); }) ->each(function ($signal) { $this->{$signal->method}(...unserialize($signal->arguments)); From 28f21900514e6ebadef21b2b4c873a8d1806faed Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 20:59:46 -0500 Subject: [PATCH 5/8] Make activity unique --- src/Activity.php | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Activity.php b/src/Activity.php index 5077c60..b14f5af 100644 --- a/src/Activity.php +++ b/src/Activity.php @@ -5,6 +5,7 @@ use BadMethodCallException; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldBeEncrypted; +use Illuminate\Contracts\Queue\ShouldBeUnique; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; use Illuminate\Queue\InteractsWithQueue; @@ -13,7 +14,7 @@ use Workflow\Middleware\WorkflowMiddleware; use Workflow\Models\StoredWorkflow; -class Activity implements ShouldBeEncrypted, ShouldQueue +class Activity implements ShouldBeEncrypted, ShouldQueue, ShouldBeUnique { use Dispatchable, InteractsWithQueue, Queueable, SerializesModels; @@ -34,6 +35,11 @@ public function __construct(int $index, StoredWorkflow $model, ...$arguments) $this->arguments = $arguments; } + public function uniqueId() + { + return $this->model->id; + } + public function handle() { if (! method_exists($this, 'execute')) { From cd49c31e8a131ef554e593c0cca51a96e30e7ab1 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 21:01:14 -0500 Subject: [PATCH 6/8] Cleanup --- src/WorkflowStub.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index 265ff76..c6791bd 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -14,7 +14,6 @@ class WorkflowStub { protected $model; - protected $await; private function __construct($model) { From f8a7ef79fb27f7a149cb4f925799a8785c882916 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Fri, 21 Oct 2022 21:54:24 -0500 Subject: [PATCH 7/8] Add signal --- src/Signal.php | 40 ++++++++++++++++++++++++++++++++++++++++ src/WorkflowStub.php | 9 +-------- 2 files changed, 41 insertions(+), 8 deletions(-) create mode 100644 src/Signal.php diff --git a/src/Signal.php b/src/Signal.php new file mode 100644 index 0000000..6279b1c --- /dev/null +++ b/src/Signal.php @@ -0,0 +1,40 @@ +model = $model; + } + + public function handle() + { + $workflow = $this->model->toWorkflow(); + + if ($workflow->running()) { + try { + $workflow->resume(); + } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound) { + $this->release(); + } + } + } +} diff --git a/src/WorkflowStub.php b/src/WorkflowStub.php index c6791bd..d471761 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -145,14 +145,7 @@ public function __call($method, $arguments) 'arguments' => serialize($arguments), ]); - while ($this->running()) { - try { - $this->fresh()->dispatch(); - break; - } catch (\Spatie\ModelStates\Exceptions\TransitionNotFound $th) { - usleep(1000); - } - } + Signal::dispatch($this->model); } } } From 2158df4965eea6767e3271e6c5cff33912006f71 Mon Sep 17 00:00:00 2001 From: Richard McDaniel Date: Sat, 22 Oct 2022 15:31:45 -0500 Subject: [PATCH 8/8] Update README --- README.md | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index fe396fc..f5f5cc6 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ php artisan vendor:publish --provider="Workflow\Providers\WorkflowServiceProvide ## Requirements -You can use any queue driver that Laravel supports but this is heavily tested against Redis. +You can use any queue driver that Laravel supports but this is heavily tested against Redis. Your cache driver must support locks. (Read: [Laravel Queues](https://laravel.com/docs/9.x/queues#unique-jobs)) ## Usage @@ -49,6 +49,40 @@ $workflow->output(); => 'activity' ``` +## Signals + +Using `WorkflowStub::await()` along with signal methods allows a workflow to wait for an external event. + +``` +class MyWorkflow extends Workflow +{ + private bool $isReady = false; + + #[SignalMethod] + public function ready() + { + $this->isReady = true; + } + + public function execute() + { + $result = yield ActivityStub::make(MyActivity::class); + + yield WorkflowStub::await(fn () => $this->isReady); + + $otherResult = yield ActivityStub::make(MyOtherActivity::class); + + return $result . $otherResult; + } +} +``` + +The workflow will reach the call to `WorkflowStub::await()` and then hibernate until some external code signals the workflow like this. + +``` +$workflow->ready(); +``` + ## Failed Workflows If a workflow fails or crashes at any point then it can be resumed from that point. Any activities that were successfully completed during the previous execution of the workflow will not be ran again. pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy