Skip to content
Open
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
16 changes: 15 additions & 1 deletion .github/workflows/php.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
6 changes: 4 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<p align="center"><a href="https://github.com/durable-workflow/workflow/actions/workflows/php.yml"><img src="https://img.shields.io/github/actions/workflow/status/durable-workflow/workflow/php.yml" alt="GitHub Workflow Status"></a> <a href="https://codecov.io/gh/durable-workflow/workflow"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/durable-workflow/workflow"></a> <a href="https://packagist.org/packages/laravel-workflow/laravel-workflow/stats"><img alt="Packagist Downloads (custom server)" src="https://img.shields.io/packagist/dt/laravel-workflow/laravel-workflow"></a>
<a href="https://durable-workflow.com/docs/installation"><img src="https://img.shields.io/badge/docs-read%20now-brightgreen" alt="Docs"></a> <a href="https://github.com/durable-workflow/workflow/blob/master/LICENSE"><img alt="Packagist License" src="https://img.shields.io/packagist/l/laravel-workflow/laravel-workflow?color=bright-green"></a></p>
<p align="center"><a href="https://github.com/durable-workflow/workflow/actions/workflows/php.yml"><img src="https://img.shields.io/github/actions/workflow/status/durable-workflow/workflow/php.yml" alt="GitHub Workflow Status"></a> <a href="https://codecov.io/gh/durable-workflow/workflow"><img alt="Codecov" src="https://img.shields.io/codecov/c/github/durable-workflow/workflow"></a> <a href="https://packagist.org/packages/durable-workflow/workflow/stats"><img alt="Packagist Downloads (custom server)" src="https://img.shields.io/packagist/dt/durable-workflow/workflow"></a>
<a href="https://durable-workflow.com/docs/installation"><img src="https://img.shields.io/badge/docs-read%20now-brightgreen" alt="Docs"></a> <a href="https://github.com/durable-workflow/workflow/blob/master/LICENSE"><img alt="Packagist License" src="https://img.shields.io/packagist/l/durable-workflow/workflow?color=bright-green"></a></p>

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.

Expand Down Expand Up @@ -73,8 +73,10 @@ $workflow->output();

The Durable Workflow package is sustained by the community via sponsors and volunteers.

- <a href="https://github.com/discovery-ukraine" target="_blank" rel="noopener sponsored">Andriy Karpishyn</a>
- <a href="https://freispace.com" target="_blank" rel="noopener sponsored">Freispace Resource Scheduling</a>
- <a href="https://github.com/hnccox" target="_blank" rel="noopener sponsored">Hugo Cox</a>
- <a href="https://translateabook.com" target="_blank" rel="noopener sponsored">Translate a Book</a>

## Monitoring

Expand Down
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
@@ -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/"
Expand Down
39 changes: 30 additions & 9 deletions src/ChildWorkflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
}
}
}
Expand All @@ -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();
}
}
4 changes: 4 additions & 0 deletions src/Providers/WorkflowServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
}
Expand Down
10 changes: 10 additions & 0 deletions src/Traits/Versions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
9 changes: 5 additions & 4 deletions src/Webhooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]);
Expand Down
4 changes: 4 additions & 0 deletions src/config/workflows.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@

'prune_age' => '1 month',

'watchdog' => [
'enabled' => env('WORKFLOW_WATCHDOG_ENABLED', true),
],

'webhooks_route' => env('WORKFLOW_WEBHOOKS_ROUTE', 'webhooks'),

'webhook_auth' => [
Expand Down
33 changes: 33 additions & 0 deletions tests/Feature/VersionWorkflowTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Tests\Feature;

use Tests\Fixtures\TestVersionedActivityV1;
use Tests\Fixtures\TestVersionedActivityV3;
use Tests\Fixtures\TestVersionMinSupportedWorkflow;
use Tests\Fixtures\TestVersionWorkflow;
Expand Down Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions tests/Unit/ChildWorkflowTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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);
}
}
3 changes: 3 additions & 0 deletions tests/Unit/Config/WorkflowsConfigTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
];

Expand Down
23 changes: 23 additions & 0 deletions tests/Unit/Providers/WorkflowServiceProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading
Loading