Prevent overlapping of jobs of different classes: Shared() – Laravel

Normally we passed a key when using WithoutOverlapping middleware on a job class. It will prevent overlapping for jobs of the same class only. Does not prevent overlapping of job for other classes even with the same id passed to WithoutOverlapping middleware.

WithoutOverlapping will prevent jobs of the same class running concurrently. But how to prevent jobs on different job classes with the same id passed to WithoutOverlapping middleware running concurrently?

Task as a example

For example if you have two job classes ActivateSubscription and DeactivateSubscription. Subscription activation and deactivation for a user cannot happen concurrently. It should process one after another. How to use a locking mechanism or prevent overlapping of two job classes.

Setup a Laravel project and create 2 job classes.

First setup fresh Laravel project. Then create two job classes as ActivateSubscription and DeactivateSubscription using below artisan command.

php artisan make:job ActivateSubscription
php artisan make:job DeactivateSubscription 

Modify created job classes

  1. Modify constructor of both job classes to accept user id as a string.
  2. Modify handle() to log information on Laravel logs.
  3. Add sleep(10) to simulate time consuming task.
  4. Add WithoutOverlapping middleware with same key for both jobs.
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;

class ActivateSubscription implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct(public string $userId)
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info("processing... subscription activation");
        sleep(10);
        Log::info("Subscription activated for user $this->userId");
    }

    public function middleware():array
    {
        return [new WithoutOverlapping('subscription:'.$this->userId)];
    }
}
<?php

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;

class DeactivateSubscription implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct(public string $userId)
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info("processing... subscription deactivation");
        sleep(10);
        Log::info("Subscription deactivated for user $this->userId");
    }

    public function middleware():array
    {
        return [new WithoutOverlapping('subscription:'.$this->userId)];
    }
}

Dispatch above created job classes

Let modify web.php file. Create two routes. One to activate subscription another to deactivate subscription.

use App\Jobs\ActivateSubscription;
use App\Jobs\DeactivateSubscription;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Route;

Route::get('/', function () {
    $userId = 201;
    Log::info("Activate subscription request for user $userId");
    ActivateSubscription::dispatch($userId);
});

Route::get('/deactivate', function () {
    $userId = 201;
    Log::info("Deactivate subscription request for user $userId");
    DeactivateSubscription::dispatch($userId);
});

Testing

Run your Laravel application using below command.

php artisan serve

Run 2 queue workers by running below command on two terminal windows.

php artisan queue:work

Send get requests to above two routes to trigger both activation and deactivation jobs.

  1. Send request to activate – http://127.0.0.1:8000/
  2. Send request to deactivate http://127.0.0.1:8000/deactivate

Below shows the Laravel Log file.

[2024-12-18 11:43:49] local.INFO: Activate subscription request for user 201  
[2024-12-18 11:43:50] local.INFO: processing... subscription activation  
[2024-12-18 11:43:50] local.INFO: Deactivate subscription request for user 201  
[2024-12-18 11:43:52] local.INFO: processing... subscription deactivation  
[2024-12-18 11:44:00] local.INFO: Subscription activated for user 201  
[2024-12-18 11:44:02] local.INFO: Subscription deactivated for user 201  

According to above log file your application tries to deactivate a subscription that is not active. If you see the “processing…” lines, subscription deactivation job start its work while subscription activation job still in the process. You can fix this issue by sharing WithoutOverlapping middleware key with both job classes.

Prevent job overlapping for different job classes by sharing the locking key

You will understand why we need to use shared() method if you continue by reading the example so far. Share WithoutOverlapping middleware key with other job classes.

namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;

class ActivateSubscription implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct(public string $userId)
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info("processing... subscription activation");
        sleep(10);
        Log::info("Subscription activated for user $this->userId");
    }

    public function middleware():array
    {
        return [
            (new WithoutOverlapping('subscription:'.$this->userId))->shared()
        ];
    }
}
namespace App\Jobs;

use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Queue\Middleware\WithoutOverlapping;
use Illuminate\Support\Facades\Log;

class DeactivateSubscription implements ShouldQueue
{
    use Queueable;

    /**
     * Create a new job instance.
     */
    public function __construct(public string $userId)
    {
        //
    }

    /**
     * Execute the job.
     */
    public function handle(): void
    {
        Log::info("processing... subscription deactivation");
        sleep(10);
        Log::info("Subscription deactivated for user $this->userId");
    }

    public function middleware():array
    {
        return [
           (new WithoutOverlapping('subscription:'.$this->userId))->shared()
        ];
    }
}

Now test your application by re-running the queue worker commands. You will Logs files like below.

[2024-12-18 11:54:37] local.INFO: Activate subscription request for user 201  
[2024-12-18 11:54:37] local.INFO: processing... subscription activation  
[2024-12-18 11:54:38] local.INFO: Deactivate subscription request for user 201  
[2024-12-18 11:54:40] local.ERROR: App\Jobs\DeactivateSubscription has been
attempted too many times. {"exception"

It will throw an exception when job overlapping happens.

Prevent exceptions and discard overlapping job

  1. Add dontRelease() method to both job classes.
  2. Overlapping job class does not added to queue.
  3. It will discard or does not execute overlapping job class.
....
 public function middleware():array
    {
        return [
            (new WithoutOverlapping('subscription:'.$this->userId))->shared()->dontRelease()
        ];
    }
....

Prevent exception and execute overlapping jobs later

  1. Add releaseAfter() method.
  2. ReleaseAfter method will release the job back to the queue after given time till it reach the maximum retry limit.
  3. Make sure to run your queue worker with –tries option to retry failed jobs.
php artisan queue:work --tries=3
....
 public function middleware():array
    {
        return [
            (new WithoutOverlapping('subscription:'.$this->userId))->shared()->dontRelease()->releaseAfter(15)
        ];
    }
....