atomic-lock

Laravel Atomic Lock: Detailed Overview

What is an Atomic Lock?

The atomic lock allows synchronization of the async/multiple processes sharing common resources. In Laravel Atomic Lock is a part of the caching module. This lock helps in handling data safely with concurrent requests. Atomic locks are also useful in reducing system loads by limiting resource-intensive tasks.

Use of Atomic Locks

Atomic locks can be used in the following scenarios.

  • Seat Booking Applications – Bus, Train, Hotels, Plane, Theater, etc seat bookings.
  • Synchronizing Tasks – Avoid overlapping of cron execution.
  • Avoid Duplicate Jobs – Avoid processing the same job again.
  • Limit Resource Usage – prevent multiple resource-intensive tasks running at the same time.

How Atomic Lock Works?

Atomic lock works by locking a key in the cache for a duration. We can release the lock after the completion of the required process. Let’s take a look in detail.

Get Lock Instance

The lock instance requires a string that should be unique to identify the process. Other optional parameters require the time in seconds (default is 0) and owner. The Owner is the unique identifier of the current process user. If the owner is passed as null then the System will generate a random string.


use Illuminate\Support\Facades\Cache;

$lock = Cache::lock("lock-key", 10); 
/**
 * Cache::lock(string $name, int $seconds = 0, string|null $owner = null)
 * returns \Illuminate\Contracts\Cache\Lock instance
 * i.e. 
 * \Illuminate\Cache\DatabaseLock,
 * \Illuminate\Cache\RedisLock
 * etc.
 * based on Cache configuration
 */

Acquire Lock Using get Method

Before using this method, look at the function’s internal code.


namespace Illuminate\Cache;
...

abstract class Lock implements LockContract
{
    ...

    /**
     * Attempt to acquire the lock.
     *
     * @param  callable|null  $callback
     * @return mixed
     */
    public function get($callback = null)
    {
        $result = $this->acquire();

        if ($result && is_callable($callback)) {
            try {
                return $callback();
            } finally {
                $this->release();
            }
        }

        return $result;
    }

    ...

}

So, get accepts one optional parameter callback. The aquire function returns bollean the value (true if the lock is acquired successfully else false). If the callback is callable, then the lock will be released after execution of the callback function.

Now, we will try to acquire a lock with the callback.


use Illuminate\Support\Facades\Cache;

$lock = Cache::lock("lock-key", 10); 

$result = $lock->get(function() {
    // Code under this function will be executed only if the lock is aquired.
});

// if result is false. The function failed to aquire lock.

Then, acquire lock without callback.


use Illuminate\Support\Facades\Cache;

$lock = Cache::lock("lock-key", 10); 

if($lock->get()){
    // Code here will be executed if lock aquired.
} else {
    // Code here will be executed if failed to aquire lock.
};

Acquire Lock Using block Method

The block method is similar to the get method but it accepts one more parameter as waiting time in seconds. We will take a look at the internal code.


namespace Illuminate\Cache;
...

abstract class Lock implements LockContract
{
    ...

    /**
     * Attempt to acquire the lock for the given number of seconds.
     *
     * @param  int  $seconds
     * @param  callable|null  $callback
     * @return mixed
     *
     * @throws \Illuminate\Contracts\Cache\LockTimeoutException
     */
    public function block($seconds, $callback = null)
    {
        $starting = $this->currentTime();

        while (! $this->acquire()) {
            Sleep::usleep($this->sleepMilliseconds * 1000);

            if ($this->currentTime() - $seconds >= $starting) {
                throw new LockTimeoutException;
            }
        }

        if (is_callable($callback)) {
            try {
                return $callback();
            } finally {
                $this->release();
            }
        }

        return true;
    }

    ...

}

The difference between block over get is,

  • This method will re-attempt to acquire a lock unless it completes the waiting time.
  • It will take sleep of sleepMilliseconds before attempting again.
  • We can set this attribute to $lock variable by using betweenBlockedAttemptsSleepFor function (like $lock->betweenBlockedAttemptsSleepFor(10)).

Role of Owner

We have seen the acquire method locks the key for an owner and returns true or false. The same owner can successfully lock the same key multiple times. Let’s take an example.


use Illuminate\Support\Facades\Cache;

$lock_owner_1 = Cache::lock("lock-key", 10); 

$lock_owner_2 = Cache::lock("lock-key", 10); 

$lock_owner_1->get() // true

$lock_owner_2->get() // false

$lock_owner_1->get() // true

$lock_owner_1->release() // true

$lock_owner_2->get() // true

So, what happened here,

  • $lock_owner_1 and $lock_owner_2 are separate owners and can not obtain a lock if another owner uses the same key.
  • But $lock_owner_1 can obtain the lock again because the lock already belongs to the owner.
  • if $lock_owner_1 releases the lock or the lock expires then only $lock_owner_2 can acquire the lock.

A scenario where setting Owner Can be helpful

Take an example of a ticket booking application.

  • The user selects a seat and proceeds to the payment gateway.
  • If the Payment gateway reverts with a success then book the ticket.
  • If failed release the seat for other users.
  • If time exceeds release the seat for other users.

Payment Gateway interacts with the application using webhooks. So, the default owner of the lock (i.e. randomly generated) will be different. But we can not release a key without the same owner.

Now, consider the request received for the seat booking.


// validate request

$lock_key = "$prefix-$vehicle-$seat"; // create key as a unique identifier for seat

$lock = Cache::lock($lock_key, 10); 

try{
    $lock->block(10) // aquire lock
    $metadata = [...prepare your metadata for gateway]
    $metadata["owner"] = $lock->owner(); // get current owner key
    $metadata["lock_key"] = $lock_key;
    // init payment procedure
} catch (LockTimeoutException $e) {
    // revert with seat is not available for now.
}

Then, the webhook reverts with the confirmation.


// from metadata extract owner and lock_key


$lock = Cache::lock($metadata["lock_key"], 10, $metadata["owner"]); 
// now owner of this lock is the same as before

try{
    $lock->block(10) // aquire lock
    // perform database activities
    // revert with the sucess
} catch (LockTimeoutException $e) {
    // if payment is succeded then start for refund as the booking is not completed.
}

Conclusion

We discussed the usefulness and technical aspects of the Atomic lock in the Laravel Caceh module. The lock is important for handling concurrent requests. You can read more about Atomic Lock in the Larave Official Documentation.

If you want to read more on Laravel, visit our blogs related to Laravel.