ksassnowski / venture

Venture allows you to create and manage complex, async workflows in your Laravel apps.
https://laravel-venture.com
MIT License
804 stars 32 forks source link

Add ability to add jobs or workflows as a group #74

Closed ksassnowski closed 1 year ago

ksassnowski commented 1 year ago

Summary

This PR introduces two new features:

Details

I frequently found myself in the position where I needed to add multiple instances of the same job to the workflow in a loop. Consider an example like this.

class SendCampaignWorkflow extends AbstractWorkflow
{
    public function __construct(private readonly Campaign $campaign)
    {
    }

    public function definition(): WorkflowDefinition
    {
        $definition = $this->define('Send email campaign');

        foreach ($this->campaign->users as $i => $user) {
            $definition->addJob(
                new SpamUser($this->campaign, $user),
                id: 'spam_user_' . $i
            );
        }

        // Other stuff...

        return $definition;
    }
}

Here, I want to add a SpamUser job to the workflow for each of the Campaign's users. Currently, this is very annoying because I have to break out of the fluent interface to some kind of loop. I also need to make sure to provide a unique id to each of these jobs by including the loop variable in the job id.

This gets even worse when I want to add a job that should only run once all of the SpamUser jobs have finished, as I would have to list each job id in the new job's dependencies.

One way to work around this would be to extract the SpamUser jobs into their own workflow and have the loop be there instead. Then I could simply add this new workflow as a nested workflow. This would also allow me to use the workflow as a dependency for other jobs, eliminating the need to enumerate each individual job id.

While this works, creating a whole separate workflow to essentially just wrap a foreach loop feels very clunky.

Solution

With this PR, you would be able to use the new each method of the WorkflowDefinition to achieve the same thing, while still maintaining the ability to continue chaining.

class SendCampaignWorkflow extends AbstractWorkflow
{
    public function __construct(private readonly Campaign $campaign)
    {
    }

    public function definition(): WorkflowDefinition
    {
        return $this->define('Send email campaign')
            ->each(
                $this->campaign->users,
                fn (User $user) => new SpamUser($this->campaign, $user),
            )
            ->addJob(new PatYourselfOnTheBack());
    }
}

The each method takes in a collection of items and a callback function. The callback function gets called for each item in the collection and should either return a WorkflowableJob or an AbstractWorkflow. Each job or nested workflow will then get added to the parent workflow.

Note that I didn't have to provide an explicit id anymore as the each method will fall back to using the registered StepIdGenerator internally. It will also enumerate each job id as we had done manually above. Similar to how addJob works, you're still able to provide an explicit ID, however. This id will still get enumerated to avoid duplicate job ids.

$this->define('Send email campaign')
    ->each(
        $this->campaign->users,
        fn (User $user) => new SpamUser($this->campaign, $user),
        // Will get turned into "spam_users_1", "spam_users_2", etc
        id: 'spam_users'
    );

You're also able to provide a name, delay, as well as an array of dependencies to this method. All of these get set on each job.

Groups

While this solves the issue of defining these jobs, there's still no good way of depending on this list of jobs. For this reason, this PR also adds the concept of groups to the DependencyGraph.

A group is essentially just a list of nodes (read: jobs), that you want to be able to address with a single name. This way, you can add a GroupDependency to a job and have it automatically resolve the correct dependencies.

The only place where a group is currently created, is when using the each method and defining an explicit id. The definition will then register all added jobs and workflows as a group in the dependency graph. The name of the group will be the provided id

class SendCampaignWorkflow extends AbstractWorkflow
{
    public function __construct(private readonly Campaign $campaign)
    {
    }

    public function definition(): WorkflowDefinition
    {
        return $this->define('Send email campaign')
            ->each(
                $this->campaign->users,
                fn (User $user) => new SpamUser($this->campaign, $user),
                // Providing an explicit id defines a group for all jobs or workflows that were
                // added by the `each` call
                id: 'spam_users'
            )
            ->addJob(
                new PatYourselfOnTheBack(), 
                // We can then depend on the entire group by using a `GroupDependency`
                dependencies: [GroupDependency::forGroup('spam_users')]
            );
    }
}

A GroupDependency will also correctly resolve the required dependencies for any nested workflows that might have been added to the parent workflow.