Design Patterns in Action: Decorator and Symfony Lock component.

Today, we have a look inside the symfony/lock component. As stated in https://symfony.com/doc/current/components/lock.html , “The Lock Component creates and manages locks, a mechanism to provide exclusive access to a shared resource.” The given example describes the idea:


$factory = new LockFactory($store);
$lock = $factory->createLock('pdf-invoice-generation');

if ($lock->acquire()) {
    // The resource "pdf-invoice-generation" is locked.
    // You can compute and generate the invoice safely here.

    $lock->release();
}

As you notice, we need a media to apply the lock. It can be a relational database (e.g PostgreSqlStore), a document store (e.g MongoDbStore), a key-value store (e.g RedisStore or MemcachedStore), the server’s memory (e.g InMemoryStore), etc.

As also explained in the site “By default, when a lock cannot be acquired, the acquire method returns false immediately. To wait (indefinitely) until the lock can be created, pass true as the argument of the acquire() method. This is called a blocking lock because the execution of your application stops until the lock is acquired.”

Whether or not a blocking lock can be applied depends on whether the class the represents the media implements the PersistingStoreInterface or the BlockingStoreInterface interface.

Redis is not a blocking store but in some cases we need to treat it as one (retry to obtain the lock if the previous attempt failed). The problem is that RedisStore class does not implement the BlockingStoreInterface because it is missing the waitTillSave() method.

The solution is given by the RetryTillSaveStore class that converts the functionality of a non-blocking store to the one of a blocking store. RetryTillSaveStore is a decorator class. In order to implements the functionality of a blocking store it uses, as a base, the functionality of a non-blocking store. As dictated by the decorator pattern, composition is used to encapsulate the functionality of the non-blocking store and use it later.


class RetryTillSaveStore implements BlockingStoreInterface, LoggerAwareInterface
{
    use LoggerAwareTrait;

    private $decorated;
    private $retrySleep;
    private $retryCount;

    /**
     * @param int $retrySleep Duration in ms between 2 retry
     * @param int $retryCount Maximum amount of retry
     */
    public function __construct(PersistingStoreInterface $decorated, int $retrySleep = 100, int $retryCount = \PHP_INT_MAX)
    {
        $this->decorated = $decorated;
        $this->retrySleep = $retrySleep;
        $this->retryCount = $retryCount;

        $this->logger = new NullLogger();
    }

    /**
     * {@inheritdoc}
     */
    public function save(Key $key)
    {
        $this->decorated->save($key);
    }

    /**
     * {@inheritdoc}
     */
    public function waitAndSave(Key $key)
    {
        $retry = 0;
        $sleepRandomness = (int) ($this->retrySleep / 10);
        do {
            try {
                $this->decorated->save($key);

                return;
            } catch (LockConflictedException $e) {
                usleep(($this->retrySleep + random_int(-$sleepRandomness, $sleepRandomness)) * 1000);
            }
        } while (++$retry < $this->retryCount);

        $this->logger->warning('Failed to store the "{resource}" lock. Abort after {retry} retry.', ['resource' => $key, 'retry' => $retry]);

        throw new LockConflictedException();
    }

    /**
     * {@inheritdoc}
     */
    public function putOffExpiration(Key $key, float $ttl)
    {
        $this->decorated->putOffExpiration($key, $ttl);
    }

    /**
     * {@inheritdoc}
     */
    public function delete(Key $key)
    {
        $this->decorated->delete($key);
    }

    /**
     * {@inheritdoc}
     */
    public function exists(Key $key)
    {
        return $this->decorated->exists($key);
    }
}

You can see, that a non-blocking store (in the form of an object that implements the PersistingStoreInterface interface) is passed through the constructor and stored as a property.

All methods of PersistingStoreInterface that remain (alongside their semantics) in the BlockingStoreInterface are being decorated, though there is no extension of their functionality. The new method required by the BlockingStoreInterface , is implemented building on the functionality of save() method.

* keep in mind that, since Symfony 5.2, the Lock class has integrated the functionality of RetryTillSaveStore and it can automatically treat a non-blocking store as a blocking one, if asked to.