Skip to content

Added comments to workflow and activity classes #247

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
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
38 changes: 38 additions & 0 deletions src/Activity.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@
use Workflow\Models\StoredWorkflow;
use Workflow\Serializers\Serializer;

/**
* Class Activity - A dispatchable job that will be dispatched to a Laravel queue for a worker to process
*
* This is an abstract class the should be extended by your own activity classes. This base class represents a
* dispatchable job that will be dispatched to a Laravel queue for a worker to process.
*
* When instantiated and dispatched to a queue, this class receives the following properties:
* - $index: The index of the activity in the workflow. Each time a workflow yields to wait for an activity, child
* workflow, or side effect, this index is incremented.
* - $now: The "current" time in ISO 8601 format. The calling workflow tracks the time when the activity is
* dispatched and that time can be used in the activity logic. This allows retried activities to be retried using
* the same time value.
* - $storedWorkflow: The database model representing the workflow from which this activity was dispatched.
* - ...$arguments: The arguments passed to execute() method of the class that extends this class.
*
* Note: you should never instantiate this class or its children directly. Instead, you should call it from a workflow
* class using the `ActivityStub::make(YourChildActivityClass::class, ...$arguments)` method.
*/
class Activity implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable;
Expand Down Expand Up @@ -87,16 +105,25 @@ public function webhookUrl(string $signalMethod = ''): string

public function handle()
{
// If the child class does not implement the execute method, throw an exception
if (! method_exists($this, 'execute')) {
throw new BadMethodCallException('Execute method not implemented.');
}

$this->container = App::make(Container::class);

// If this activity has already been executed and a return value has been stored in the database, then
// return. The middleware will handle dispatching its parent workflow to continue the execution of the
// workflow.
if ($this->storedWorkflow->logs()->whereIndex($this->index)->exists()) {
return;
}

// Execute the child class's execute method, passing in the $...arguments that were passed to the
// ActivityStub::make() method. If the child class throws an exception, it will be caught here and
// the exception will be stored in the database. If the exception is a NonRetryableExceptionContract,
// then the workflow will be failed. If the exception is not a NonRetryableExceptionContract, then
// the exception will be rethrown, caught by the middleware, and the activity will be retried.
try {
return $this->{'execute'}(...$this->resolveClassMethodDependencies($this->arguments, $this, 'execute'));
} catch (\Throwable $throwable) {
Expand All @@ -117,20 +144,29 @@ public function handle()
public function middleware()
{
return [
// Ensure that this activity cannot run while the workflow is executing
new WithoutOverlappingMiddleware(
$this->storedWorkflow->id,
WithoutOverlappingMiddleware::ACTIVITY,
0,
$this->timeout
),
// Dispatch activity lifecycle events, execute the activity, and store the activity output in the database
new ActivityMiddleware(),
];
}

/**
* This method is called when the activity throws an exception that is a NonRetryableExceptionContract or
* if the activity throws an exception that is not a NonRetryableExceptionContract but the activity is not
* retryable (i.e. it has been released back to the queue more than $tries times).
*/
public function failed(Throwable $throwable): void
{
// Instantiate the WorkflowStub class for the workflow from which this activity was dispatched
$workflow = $this->storedWorkflow->toWorkflow();

// Build a serializable version of the exception
$file = new SplFileObject($throwable->getFile());
$iterator = new LimitIterator($file, max(0, $throwable->getLine() - 4), 7);

Expand All @@ -146,6 +182,8 @@ public function failed(Throwable $throwable): void
'snippet' => array_slice(iterator_to_array($iterator), 0, 7),
];

// Dispatch a job to store the exception in the database and to dispatch the parent workflow to continue
// the execution of the workflow.
Exception::dispatch(
$this->index,
$this->now,
Expand Down
34 changes: 34 additions & 0 deletions src/ActivityStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,55 @@
use Throwable;
use Workflow\Serializers\Serializer;

/** ActivityStub - A class called from within a Workflow to execute an Activity
*
* This class is called from within the execute() method of a Workflow class to execute an Activity. It has three
* method:
* - async: accept a callable ane execute it as if it were a defined child workflow
* - make: returns an unfilled promise if that activity has not been executed yet, otherwise returns a fulfilled
* promise with the result of the activity from when it was executed.
* - all: accepts an array of ActivityStub::make() promises and returns an array of unfilled/fulfilled promises. It is
* similar to ActivityStub::make() but it allows the system to execute multiple activities at the same time.
*/
final class ActivityStub
{
/**
* This method accepts an array of ActivityStub::make() promises and returns an array of unfilled/fulfilled promises.
* It is similar to ActivityStub::make() but it allows the system to execute multiple activities at the same time.
*/
public static function all(iterable $promises): PromiseInterface
{
return all([...$promises]);
}

/**
* This method accepts a callable and returns a promise that will execute that callable as if it were a defined
* child workflow. It is used to execute a callback function directly from within a Workflow without having to
* define a child workflow.
*/
public static function async(callable $callback): PromiseInterface
{
return ChildWorkflowStub::make(AsyncWorkflow::class, new SerializableClosure($callback));
}

/**
* This method accepts the class name for an activity that extends the Activity class and the arguments to pass
* to that activity. When this is called from within a workflow for the first time, this will dispatch the
* activity to the queue and return an unfilled promise. The workflow will then exit and wait for the activity
* to complete. When this is called again (when the workflow is resumed), this will return a fulfilled promise
* with the result of the activity and the workflow will continue with the result.
*/
public static function make($activity, ...$arguments): PromiseInterface
{
$context = WorkflowStub::getContext();

// Query the database to see if the activity has already been executed
$log = $context->storedWorkflow->logs()
->whereIndex($context->index)
->first();

// If we are running unit tests, and we have a mock for this activity, then
// use the mock instead of dispatching the activity.
if (WorkflowStub::faked()) {
$mocks = WorkflowStub::mocks();

Expand All @@ -52,6 +81,8 @@ public static function make($activity, ...$arguments): PromiseInterface
}
}

// If the activity has already been executed and the result was available in the database, then
// return a fulfilled promise with that result.
if ($log) {
++$context->index;
WorkflowStub::setContext($context);
Expand All @@ -66,6 +97,9 @@ public static function make($activity, ...$arguments): PromiseInterface
return resolve($result);
}

// At this point, we know that the activity has not yet been executed. Dispatch it to the queue and
// return an unfilled promise to signal to the workflow that it can exit and wait for the activity to
// complete.
$activity::dispatch($context->index, $context->now, $context->storedWorkflow, ...$arguments);

++$context->index;
Expand Down
23 changes: 23 additions & 0 deletions src/Middleware/ActivityMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,29 @@
use Workflow\Events\ActivityFailed;
use Workflow\Events\ActivityStarted;

/**
* Class ActivityMiddleware
*
* This middleware orchestrates the complete lifecycle of activity execution within a workflow.
*
* Execution Flow:
* 1. Dispatch ActivityStarted event with activity details and unique UUID
* 2. Allow the activity to execute ($next($job))
* 3. Store the activity output/result in the database
* 4. Attempt to update the workflow status to "pending" in preparation for continuation
* 5. If status transition is valid: Dispatch the parent workflow back to the queue to continue execution
* 6. If status transition fails (workflow already "running"): Release this activity back to the queue for retry
* 7. Dispatch ActivityCompleted event
*
* Important: Due to the state transition logic in step 4-6, activities may be completed more than once
* if there are timing conflicts with workflow state changes.
*
* On failure, it captures detailed exception information (including code snippets) and dispatches
* ActivityFailed event before re-throwing the exception.
*
* This middleware acts as the bridge that allows activities to seamlessly hand their results back
* to their parent workflow for continued execution, while managing state transitions safely.
*/
final class ActivityMiddleware
{
public function handle($job, $next): void
Expand Down
19 changes: 19 additions & 0 deletions src/Middleware/WithoutOverlappingMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@
use Illuminate\Support\InteractsWithTime;
use Illuminate\Support\Str;

/**
* Class WithoutOverlappingMiddleware
*
* This middleware ensures mutual exclusion between workflow execution and activity execution for a specific
* workflow instance using a semaphore-based locking system.
*
* Key Behaviors:
* - **Workflow Exclusivity**: Only 1 instance of a workflow can run at a time
* - **Workflow-Activity Mutual Exclusion**: When a workflow is executing, none of its activities can run simultaneously
* - **Activity Concurrency**: When the workflow is NOT executing, multiple activities from that workflow can run simultaneously
* - **Per-Workflow Isolation**: This applies per workflow instance (identified by workflowId), not globally
*
* In simple terms: when a workflow is executing, nothing else for that workflow is running. If the workflow is not
* being executed, then children of the workflow can run freely.
*
* This design prevents race conditions while maximizing concurrency - the workflow logic (the "conductor") has
* exclusive control when making decisions, but when it's waiting for work to be done, multiple activities
* (the "workers") can execute in parallel without interfering with each other.
*/
class WithoutOverlappingMiddleware
{
use InteractsWithTime;
Expand Down
17 changes: 17 additions & 0 deletions src/Workflow.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,19 @@
use Workflow\States\WorkflowWaitingStatus;
use Workflow\Traits\Sagas;

/**
* Workflow - A dispatchable job
*
* This is an abstract class the should be extended by your own workflow classes. This base class represents a
* dispatchable job that will be dispatched to a Laravel queue for a worker to process.
*
* When instantiated and dispatched to a queue, this class receives the following properties:
* - $storedWorkflow: The database model representing this workflow.
* - ...$arguments: The arguments that will be passed to execute() method of the class that extends this class.
*
* Note: you should never instantiate this class or its children directly. Instead, you should instantiate it using the
* `WorkflowStub::make(YourChildWorkflowClass::class)->start($..$arguments)`.
*/
class Workflow implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable;
Expand Down Expand Up @@ -78,6 +91,8 @@ public function query($method)

public function middleware()
{
// This middleware is used to prevent multiple instances of the same workflow from running at the same time.
// @see WithoutOverlappingMiddleware for details on its implementation.
$parentWorkflow = $this->storedWorkflow->parents()
->first();

Expand All @@ -93,6 +108,8 @@ public function middleware()

public function failed(Throwable $throwable): void
{
// If an activity is dispatched from a workflow and it fails, the workflow will receive the exception. If the
// workflow does not handle the exception, it will be caught here and the workflow will be marked as failed.
try {
$this->storedWorkflow->toWorkflow()
->fail($throwable);
Expand Down
16 changes: 16 additions & 0 deletions src/WorkflowStub.php
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,13 @@ public function startAsChild(StoredWorkflow $parentWorkflow, int $index, $now, .
$this->start(...$arguments);
}

/**
* This method is called when a defined workflow job fails (i.e. the workflow does not elegantly handle an exception
* thrown by an activity).
*
* This method will store the exception in the database and transition the workflow to the failed state. It will then
* dispatch the WorkflowFailed event and call the fail method on any parent workflows.
*/
public function fail($exception): void
{
$this->storedWorkflow->exceptions()
Expand Down Expand Up @@ -252,6 +259,10 @@ public function fail($exception): void
});
}

/**
* This method is called from an activity or child workflow with it completes. This method stores the result in the
* database and dispatches the workflow back to the queue so that it can be continued.
*/
public function next($index, $now, $class, $result): void
{
try {
Expand All @@ -269,6 +280,11 @@ public function next($index, $now, $class, $result): void
$this->dispatch();
}

/**
* This method is called when a workflow is started and each time that an activity or child workflow completes.
* This method will update the status field for this workflow in the database to "pending", and will then
* dispatch the workflow to the queue so that it can be continued.
*/
private function dispatch(): void
{
if ($this->created()) {
Expand Down
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