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. 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..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')) { 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/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/SignalMethod.php b/src/SignalMethod.php new file mode 100644 index 0000000..bf6a265 --- /dev/null +++ b/src/SignalMethod.php @@ -0,0 +1,9 @@ +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()) { - $index = $this->index++; + $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($log, function($query, $log) { + $query->where('created_at', '>', $log->created_at); + }) + ->each(function ($signal) { + $this->{$signal->method}(...unserialize($signal->arguments)); + }); $current = $this->coroutine->current(); - $log = $this->model->logs()->whereIndex($index)->first(); + if ($current instanceof PromiseInterface) { + $resolved = false; + + $current->then(function ($value) use (&$resolved) { + $resolved = true; + + $this->model->logs()->create([ + 'index' => $this->index, + 'result' => serialize(true), + ]); + + $log = $this->model->logs()->whereIndex($this->index)->first(); - if ($log) { - $this->coroutine->send(unserialize($log->result)); + $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($this->index)->first(); - $current->activity()::dispatch($index, $this->model, ...$current->arguments()); + if ($log) { + $this->coroutine->send(unserialize($log->result)); + } else { + $this->model->status->transitionTo(WorkflowWaitingStatus::class); - return; + $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 7147076..d471761 100644 --- a/src/WorkflowStub.php +++ b/src/WorkflowStub.php @@ -2,11 +2,14 @@ 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 { @@ -36,6 +39,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 +64,7 @@ public function output() public function running() { - return ! in_array($this->status(), [ + return !in_array($this->status(), [ WorkflowCompletedStatus::class, WorkflowFailedStatus::class, ]); @@ -110,4 +126,26 @@ 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), + ]); + + Signal::dispatch($this->model); + } + } } 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..7093f57 100644 --- a/tests/Feature/WorkflowTest.php +++ b/tests/Feature/WorkflowTest.php @@ -3,21 +3,68 @@ 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); $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 +96,8 @@ public function testRecoveredFailed() $workflow->fresh()->start(); + $workflow->cancel(); + while ($workflow->running()); $this->assertSame(WorkflowCompletedStatus::class, $workflow->status()); 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 c53494a..3bbbde7 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 = false; + + #[SignalMethod] + public function cancel() + { + $this->canceled = true; + } + + public function execute($shouldFail = false, $shouldAssert = false) { + if ($shouldAssert) + assert($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; } } 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