[Lock] Create a lock component

This commit is contained in:
Jérémy Derussé 2016-12-28 21:33:18 +01:00 committed by Fabien Potencier
parent 0e92e0a7ba
commit 018e0fc330
44 changed files with 2976 additions and 0 deletions

View File

@ -54,6 +54,7 @@
"symfony/inflector": "self.version",
"symfony/intl": "self.version",
"symfony/ldap": "self.version",
"symfony/lock": "self.version",
"symfony/monolog-bridge": "self.version",
"symfony/options-resolver": "self.version",
"symfony/process": "self.version",

3
src/Symfony/Component/Lock/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
composer.lock
phpunit.xml
vendor/

View File

@ -0,0 +1,7 @@
CHANGELOG
=========
3.3.0
-----
* added the component

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* Base ExceptionInterface for the Lock Component.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface ExceptionInterface
{
}

View File

@ -0,0 +1,19 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* LockAcquiringException is thrown when an issue happens during the acquisition of a lock.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LockAcquiringException extends \RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* LockConflictedException is thrown when a lock is acquired by someone else.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LockConflictedException extends \RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* LockReleasingException is thrown when an issue happens during the release of a lock.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LockReleasingException extends \RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* LockStorageException is thrown when an issue happens during the manipulation of a lock in a store.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LockStorageException extends \RuntimeException implements ExceptionInterface
{
}

View File

@ -0,0 +1,21 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Exception;
/**
* NotSupportedException is thrown when an unsupported method is called.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class NotSupportedException extends \LogicException implements ExceptionInterface
{
}

View File

@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
/**
* Factory provides method to create locks.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class Factory implements LoggerAwareInterface
{
use LoggerAwareTrait;
private $store;
public function __construct(StoreInterface $store)
{
$this->store = $store;
$this->logger = new NullLogger();
}
/**
* Creates a lock for the given resource.
*
* @param string $resource The resource to lock
* @param float $ttl maximum expected lock duration
*
* @return Lock
*/
public function createLock($resource, $ttl = 300.0)
{
$lock = new Lock(new Key($resource), $this->store, $ttl);
$lock->setLogger($this->logger);
return $lock;
}
}

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock;
/**
* Key is a container for the state of the locks in stores.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
final class Key
{
private $resource;
private $state = array();
/**
* @param string $resource
*/
public function __construct($resource)
{
$this->resource = (string) $resource;
}
public function __toString()
{
return $this->resource;
}
/**
* @param string $stateKey
*
* @return bool
*/
public function hasState($stateKey)
{
return isset($this->state[$stateKey]);
}
/**
* @param string $stateKey
* @param mixed $state
*/
public function setState($stateKey, $state)
{
$this->state[$stateKey] = $state;
}
/**
* @param string $stateKey
*/
public function removeState($stateKey)
{
unset($this->state[$stateKey]);
}
/**
* @param $stateKey
*
* @return mixed
*/
public function getState($stateKey)
{
return $this->state[$stateKey];
}
}

View File

@ -0,0 +1,19 @@
Copyright (c) 2016-2017 Fabien Potencier
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished
to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.

View File

@ -0,0 +1,123 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\LockReleasingException;
/**
* Lock is the default implementation of the LockInterface.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
final class Lock implements LockInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
private $store;
private $key;
private $ttl;
/**
* @param Key $key
* @param StoreInterface $store
* @param float|null $ttl
*/
public function __construct(Key $key, StoreInterface $store, $ttl = null)
{
$this->store = $store;
$this->key = $key;
$this->ttl = $ttl;
$this->logger = new NullLogger();
}
/**
* {@inheritdoc}
*/
public function acquire($blocking = false)
{
try {
if (!$blocking) {
$this->store->save($this->key);
} else {
$this->store->waitAndSave($this->key);
}
$this->logger->info('Lock successfully acquired for "{resource}".', array('resource' => $this->key));
if ($this->ttl) {
$this->refresh();
}
return true;
} catch (LockConflictedException $e) {
$this->logger->warning('Failed to lock the "{resource}". Someone else already acquired the lock.', array('resource' => $this->key));
if ($blocking) {
throw $e;
}
return false;
} catch (\Exception $e) {
$this->logger->warning('Failed to lock the "{resource}".', array('resource' => $this->key, 'exception' => $e));
throw new LockAcquiringException('', 0, $e);
}
}
/**
* {@inheritdoc}
*/
public function refresh()
{
if (!$this->ttl) {
throw new InvalidArgumentException('You have to define an expiration duration.');
}
try {
$this->store->putOffExpiration($this->key, $this->ttl);
$this->logger->info('Expiration defined for "{resource}" lock for "{ttl}" seconds.', array('resource' => $this->key, 'ttl' => $this->ttl));
} catch (LockConflictedException $e) {
$this->logger->warning('Failed to define an expiration for the "{resource}" lock, someone else acquired the lock.', array('resource' => $this->key));
throw $e;
} catch (\Exception $e) {
$this->logger->warning('Failed to define an expiration for the "{resource}" lock.', array('resource' => $this->key, 'exception' => $e));
throw new LockAcquiringException('', 0, $e);
}
}
/**
* {@inheritdoc}
*/
public function isAcquired()
{
return $this->store->exists($this->key);
}
/**
* {@inheritdoc}
*/
public function release()
{
$this->store->delete($this->key);
if ($this->store->exists($this->key)) {
$this->logger->warning('Failed to release the "{resource}" lock.', array('resource' => $this->key));
throw new LockReleasingException();
}
}
}

View File

@ -0,0 +1,59 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock;
use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\LockReleasingException;
/**
* LockInterface defines an interface to manipulate the status of a lock.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface LockInterface
{
/**
* Acquires the lock. If the lock is acquired by someone else, the parameter `blocking` determines whether or not
* the the call should block until the release of the lock.
*
* @param bool $blocking Whether or not the Lock should wait for the release of someone else
*
* @return bool Whether or not the lock had been acquired.
*
* @throws LockConflictedException If the lock is acquired by someone else in blocking mode
* @throws LockAcquiringException If the lock can not be acquired
*/
public function acquire($blocking = false);
/**
* Increase the duration of an acquired lock.
*
* @throws LockConflictedException If the lock is acquired by someone else
* @throws LockAcquiringException If the lock can not be refreshed
*/
public function refresh();
/**
* Returns whether or not the lock is acquired.
*
* @return bool
*/
public function isAcquired();
/**
* Release the lock.
*
* @throws LockReleasingException If the lock can not be released
*/
public function release();
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Quorum;
use Symfony\Component\Lock\QuorumInterface;
/**
* ConsensusStrategy is a QuorumInterface implementation where strictly more than 50% items should be successful.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class ConsensusStrategy implements QuorumInterface
{
/**
* {@inheritdoc}
*/
public function isMet($numberOfSuccess, $numberOfItems)
{
return $numberOfSuccess > ($numberOfItems / 2);
}
/**
* {@inheritdoc}
*/
public function canBeMet($numberOfFailure, $numberOfItems)
{
return $numberOfFailure < ($numberOfItems / 2);
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Quorum;
use Symfony\Component\Lock\QuorumInterface;
/**
* UnanimousStrategy is a QuorumInterface implementation where 100% of elements should be successful.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class UnanimousStrategy implements QuorumInterface
{
/**
* {@inheritdoc}
*/
public function isMet($numberOfSuccess, $numberOfItems)
{
return $numberOfSuccess === $numberOfItems;
}
/**
* {@inheritdoc}
*/
public function canBeMet($numberOfFailure, $numberOfItems)
{
return $numberOfFailure === 0;
}
}

View File

@ -0,0 +1,43 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock;
/**
* QuorumInterface defines an interface to indicate when a quorum is met and can be met.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface QuorumInterface
{
/**
* Returns whether or not the quorum is met.
*
* @param int $numberOfSuccess
* @param int $numberOfItems
*
* @return bool
*/
public function isMet($numberOfSuccess, $numberOfItems);
/**
* Returns whether or not the quorum *could* be met.
*
* This method does not mean the quorum *would* be met for sure, but can be useful to stop a process early when you
* known there is no chance to meet the quorum.
*
* @param int $numberOfFailure
* @param int $numberOfItems
*
* @return bool
*/
public function canBeMet($numberOfFailure, $numberOfItems);
}

View File

@ -0,0 +1,11 @@
Lock Component
==============
Resources
---------
* [Documentation](https://symfony.com/doc/master/components/lock.html)
* [Contributing](https://symfony.com/doc/current/contributing/index.html)
* [Report issues](https://github.com/symfony/symfony/issues) and
[send Pull Requests](https://github.com/symfony/symfony/pulls)
in the [main Symfony repository](https://github.com/symfony/symfony)

View File

@ -0,0 +1,174 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Store;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\NotSupportedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\QuorumInterface;
use Symfony\Component\Lock\StoreInterface;
/**
* CombinedStore is a StoreInterface implementation able to manage and synchronize several StoreInterfaces.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CombinedStore implements StoreInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
/** @var StoreInterface[] */
private $stores;
/** @var QuorumInterface */
private $quorum;
/**
* @param StoreInterface[] $stores The list of synchronized stores
* @param QuorumInterface $quorum
*
* @throws InvalidArgumentException
*/
public function __construct(array $stores, QuorumInterface $quorum)
{
foreach ($stores as $store) {
if (!$store instanceof StoreInterface) {
throw new InvalidArgumentException(sprintf('The store must implement "%s". Got "%s".', StoreInterface::class, get_class($store)));
}
}
$this->stores = $stores;
$this->quorum = $quorum;
$this->logger = new NullLogger();
}
/**
* {@inheritdoc}
*/
public function save(Key $key)
{
$successCount = 0;
$failureCount = 0;
$storesCount = count($this->stores);
foreach ($this->stores as $store) {
try {
$store->save($key);
++$successCount;
} catch (\Exception $e) {
$this->logger->warning('One store failed to save the "{resource}" lock.', array('resource' => $key, 'store' => $store, 'exception' => $e));
++$failureCount;
}
if (!$this->quorum->canBeMet($failureCount, $storesCount)) {
break;
}
}
if ($this->quorum->isMet($successCount, $storesCount)) {
return;
}
$this->logger->warning('Failed to store the "{resource}" lock. Quorum has not been met.', array('resource' => $key, 'success' => $successCount, 'failure' => $failureCount));
// clean up potential locks
$this->delete($key);
throw new LockConflictedException();
}
public function waitAndSave(Key $key)
{
throw new NotSupportedException(sprintf('The store "%s" does not supports blocking locks.', get_class($this)));
}
/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $ttl)
{
$successCount = 0;
$failureCount = 0;
$storesCount = count($this->stores);
foreach ($this->stores as $store) {
try {
$store->putOffExpiration($key, $ttl);
++$successCount;
} catch (\Exception $e) {
$this->logger->warning('One store failed to put off the expiration of the "{resource}" lock.', array('resource' => $key, 'store' => $store, 'exception' => $e));
++$failureCount;
}
if (!$this->quorum->canBeMet($failureCount, $storesCount)) {
break;
}
}
if ($this->quorum->isMet($successCount, $storesCount)) {
return;
}
$this->logger->warning('Failed to define the expiration for the "{resource}" lock. Quorum has not been met.', array('resource' => $key, 'success' => $successCount, 'failure' => $failureCount));
// clean up potential locks
$this->delete($key);
throw new LockConflictedException();
}
/**
* {@inheritdoc}
*/
public function delete(Key $key)
{
foreach ($this->stores as $store) {
try {
$store->delete($key);
} catch (\Exception $e) {
$this->logger->notice('One store failed to delete the "{resource}" lock.', array('resource' => $key, 'store' => $store, 'exception' => $e));
} catch (\Throwable $e) {
$this->logger->notice('One store failed to delete the "{resource}" lock.', array('resource' => $key, 'store' => $store, 'exception' => $e));
}
}
}
/**
* {@inheritdoc}
*/
public function exists(Key $key)
{
$successCount = 0;
$failureCount = 0;
$storesCount = count($this->stores);
foreach ($this->stores as $store) {
if ($store->exists($key)) {
++$successCount;
} else {
++$failureCount;
}
if ($this->quorum->isMet($successCount, $storesCount)) {
return true;
}
if (!$this->quorum->canBeMet($failureCount, $storesCount)) {
return false;
}
}
return false;
}
}

View File

@ -0,0 +1,138 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Store;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\LockStorageException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* FlockStore is a StoreInterface implementation using the FileSystem flock.
*
* Original implementation in \Symfony\Component\Filesystem\LockHandler.
*
* @author Jérémy Derussé <jeremy@derusse.com>
* @author Grégoire Pineau <lyrixx@lyrixx.info>
* @author Romain Neutron <imprec@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
class FlockStore implements StoreInterface
{
private $lockPath;
/**
* @param string $lockPath the directory to store the lock
*
* @throws LockStorageException If the lock directory could not be created or is not writable
*/
public function __construct($lockPath)
{
if (!is_dir($lockPath) || !is_writable($lockPath)) {
throw new InvalidArgumentException(sprintf('The directory "%s" is not writable.', $lockPath));
}
$this->lockPath = $lockPath;
}
/**
* {@inheritdoc}
*/
public function save(Key $key)
{
$this->lock($key, false);
}
/**
* {@inheritdoc}
*/
public function waitAndSave(Key $key)
{
$this->lock($key, true);
}
private function lock(Key $key, $blocking)
{
// The lock is maybe already acquired.
if ($key->hasState(__CLASS__)) {
return;
}
$fileName = sprintf('%s/sf.%s.%s.lock',
$this->lockPath,
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
hash('sha256', $key)
);
// Silence error reporting
set_error_handler(function () {
});
if (!$handle = fopen($fileName, 'r')) {
if ($handle = fopen($fileName, 'x')) {
chmod($fileName, 0444);
} elseif (!$handle = fopen($fileName, 'r')) {
usleep(100); // Give some time for chmod() to complete
$handle = fopen($fileName, 'r');
}
}
restore_error_handler();
if (!$handle) {
$error = error_get_last();
throw new LockStorageException($error['message'], 0, null);
}
// On Windows, even if PHP doc says the contrary, LOCK_NB works, see
// https://bugs.php.net/54129
if (!flock($handle, LOCK_EX | ($blocking ? 0 : LOCK_NB))) {
fclose($handle);
throw new LockConflictedException();
}
$key->setState(__CLASS__, $handle);
}
/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $ttl)
{
// do nothing, the flock locks forever.
}
/**
* {@inheritdoc}
*/
public function delete(Key $key)
{
// The lock is maybe not acquired.
if (!$key->hasState(__CLASS__)) {
return;
}
$handle = $key->getState(__CLASS__);
flock($handle, LOCK_UN | LOCK_NB);
fclose($handle);
$key->removeState(__CLASS__);
}
/**
* {@inheritdoc}
*/
public function exists(Key $key)
{
return $key->hasState(__CLASS__);
}
}

View File

@ -0,0 +1,179 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Store;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* MemcachedStore is a StoreInterface implementation using Memcached as store engine.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class MemcachedStore implements StoreInterface
{
private $memcached;
private $initialTtl;
/** @var bool */
private $useExtendedReturn;
public static function isSupported()
{
return extension_loaded('memcached');
}
/**
* @param \Memcached $memcached
* @param int $initialTtl the expiration delay of locks in seconds
*/
public function __construct(\Memcached $memcached, $initialTtl = 300)
{
if (!static::isSupported()) {
throw new InvalidArgumentException('Memcached extension is required');
}
if ($initialTtl < 1) {
throw new InvalidArgumentException(sprintf('%s() expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl));
}
$this->memcached = $memcached;
$this->initialTtl = $initialTtl;
}
/**
* {@inheritdoc}
*/
public function save(Key $key)
{
$token = $this->getToken($key);
if ($this->memcached->add((string) $key, $token, (int) ceil($this->initialTtl))) {
return;
}
// the lock is already acquire. It could be us. Let's try to put off.
$this->putOffExpiration($key, $this->initialTtl);
}
public function waitAndSave(Key $key)
{
throw new InvalidArgumentException(sprintf('The store "%s" does not supports blocking locks.', get_class($this)));
}
/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $ttl)
{
if ($ttl < 1) {
throw new InvalidArgumentException(sprintf('%s() expects a TTL greater or equals to 1. Got %s.', __METHOD__, $ttl));
}
// Interface defines a float value but Store required an integer.
$ttl = (int) ceil($ttl);
$token = $this->getToken($key);
list($value, $cas) = $this->getValueAndCas($key);
// Could happens when we ask a putOff after a timeout but in luck nobody steal the lock
if (\Memcached::RES_NOTFOUND === $this->memcached->getResultCode()) {
if ($this->memcached->add((string) $key, $token, $ttl)) {
return;
}
// no luck, with concurrency, someone else acquire the lock
throw new LockConflictedException();
}
// Someone else steal the lock
if ($value !== $token) {
throw new LockConflictedException();
}
if (!$this->memcached->cas($cas, (string) $key, $token, $ttl)) {
throw new LockConflictedException();
}
}
/**
* {@inheritdoc}
*/
public function delete(Key $key)
{
$token = $this->getToken($key);
list($value, $cas) = $this->getValueAndCas($key);
if ($value !== $token) {
// we are not the owner of the lock. Nothing to do.
return;
}
// To avoid concurrency in deletion, the trick is to extends the TTL then deleting the key
if (!$this->memcached->cas($cas, (string) $key, $token, 2)) {
// Someone steal our lock. It does not belongs to us anymore. Nothing to do.
return;
}
// Now, we are the owner of the lock for 2 more seconds, we can delete it.
$this->memcached->delete((string) $key);
}
/**
* {@inheritdoc}
*/
public function exists(Key $key)
{
return $this->memcached->get((string) $key) === $this->getToken($key);
}
/**
* Retrieve an unique token for the given key.
*
* @param Key $key
*
* @return string
*/
private function getToken(Key $key)
{
if (!$key->hasState(__CLASS__)) {
$token = base64_encode(random_bytes(32));
$key->setState(__CLASS__, $token);
}
return $key->getState(__CLASS__);
}
private function getValueAndCas(Key $key)
{
if (null === $this->useExtendedReturn) {
$this->useExtendedReturn = version_compare(phpversion('memcached'), '2.9.9', '>');
}
if ($this->useExtendedReturn) {
$extendedReturn = $this->memcached->get((string) $key, null, \Memcached::GET_EXTENDED);
if ($extendedReturn === \Memcached::GET_ERROR_RETURN_VALUE) {
return array($extendedReturn, 0.0);
}
return array($extendedReturn['value'], $extendedReturn['cas']);
}
$cas = 0.0;
$value = $this->memcached->get((string) $key, null, $cas);
return array($value, $cas);
}
}

View File

@ -0,0 +1,153 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Store;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* RedisStore is a StoreInterface implementation using Redis as store engine.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class RedisStore implements StoreInterface
{
private $redis;
private $initialTtl;
/**
* @param \Redis|\RedisArray|\RedisCluster|\Predis\Client $redisClient
* @param float $initialTtl the expiration delay of locks in seconds
*/
public function __construct($redisClient, $initialTtl = 300.0)
{
if (!$redisClient instanceof \Redis && !$redisClient instanceof \RedisArray && !$redisClient instanceof \RedisCluster && !$redisClient instanceof \Predis\Client) {
throw new InvalidArgumentException(sprintf('%s() expects parameter 1 to be Redis, RedisArray, RedisCluster or Predis\Client, %s given', __METHOD__, is_object($redisClient) ? get_class($redisClient) : gettype($redisClient)));
}
if ($initialTtl <= 0) {
throw new InvalidArgumentException(sprintf('%s() expects a strictly positive TTL. Got %d.', __METHOD__, $initialTtl));
}
$this->redis = $redisClient;
$this->initialTtl = $initialTtl;
}
/**
* {@inheritdoc}
*/
public function save(Key $key)
{
$script = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return redis.call("set", KEYS[1], ARGV[1], "NX", "PX", ARGV[2])
end
';
$expire = (int) ceil($this->initialTtl * 1000);
if (!$this->evaluate($script, (string) $key, array($this->getToken($key), $expire))) {
throw new LockConflictedException();
}
}
public function waitAndSave(Key $key)
{
throw new InvalidArgumentException(sprintf('The store "%s" does not supports blocking locks.', get_class($this)));
}
/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $ttl)
{
$script = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
else
return 0
end
';
$expire = (int) ceil($ttl * 1000);
if (!$this->evaluate($script, (string) $key, array($this->getToken($key), $expire))) {
throw new LockConflictedException();
}
}
/**
* {@inheritdoc}
*/
public function delete(Key $key)
{
$script = '
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
';
$this->evaluate($script, (string) $key, array($this->getToken($key)));
}
/**
* {@inheritdoc}
*/
public function exists(Key $key)
{
return $this->redis->get((string) $key) === $this->getToken($key);
}
/**
* Evaluates a script in the corresponding redis client.
*
* @param string $script
* @param string $resource
* @param array $args
*
* @return mixed
*/
private function evaluate($script, $resource, array $args)
{
if ($this->redis instanceof \Redis || $this->redis instanceof \RedisCluster) {
return $this->redis->eval($script, array_merge(array($resource), $args), 1);
}
if ($this->redis instanceof \RedisArray) {
return $this->redis->_instance($this->redis->_target($resource))->eval($script, array_merge(array($resource), $args), 1);
}
// Have to be a \Predis\Client
return call_user_func_array(array($this->redis, 'eval'), array_merge(array($script, 1, $resource), $args));
}
/**
* Retrieves an unique token for the given key.
*
* @param Key $key
*
* @return string
*/
private function getToken(Key $key)
{
if (!$key->hasState(__CLASS__)) {
$token = base64_encode(random_bytes(32));
$key->setState(__CLASS__, $token);
}
return $key->getState(__CLASS__);
}
}

View File

@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Store;
use Psr\Log\LoggerAwareInterface;
use Psr\Log\LoggerAwareTrait;
use Psr\Log\NullLogger;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* RetryTillSaveStore is a StoreInterface implementation which decorate a non blocking StoreInterface to provide a
* blocking storage.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class RetryTillSaveStore implements StoreInterface, LoggerAwareInterface
{
use LoggerAwareTrait;
private $decorated;
private $retrySleep;
private $retryCount;
/**
* @param StoreInterface $decorated The decorated StoreInterface
* @param int $retrySleep Duration in ms between 2 retry
* @param int $retryCount Maximum amount of retry
*/
public function __construct(StoreInterface $decorated, $retrySleep = 100, $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.', array('resource' => $key, 'retry' => $retry));
throw new LockConflictedException();
}
/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $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);
}
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Store;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\NotSupportedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* SemaphoreStore is a StoreInterface implementation using Semaphore as store engine.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class SemaphoreStore implements StoreInterface
{
public static function isSupported()
{
return extension_loaded('sysvsem');
}
public function __construct()
{
if (!static::isSupported()) {
throw new InvalidArgumentException('Semaphore extension (sysvsem) is required');
}
}
/**
* {@inheritdoc}
*/
public function save(Key $key)
{
$this->lock($key, false);
}
/**
* {@inheritdoc}
*/
public function waitAndSave(Key $key)
{
$this->lock($key, true);
}
private function lock(Key $key, $blocking)
{
if ($key->hasState(__CLASS__)) {
return;
}
$resource = sem_get(crc32($key));
if (PHP_VERSION_ID < 50601) {
if (!$blocking) {
throw new NotSupportedException(sprintf('The store "%s" does not supports non blocking locks.', get_class($this)));
}
$acquired = sem_acquire($resource);
} else {
$acquired = sem_acquire($resource, !$blocking);
}
if (!$acquired) {
throw new LockConflictedException();
}
$key->setState(__CLASS__, $resource);
}
/**
* {@inheritdoc}
*/
public function delete(Key $key)
{
// The lock is maybe not acquired.
if (!$key->hasState(__CLASS__)) {
return;
}
$resource = $key->getState(__CLASS__);
sem_release($resource);
$key->removeState(__CLASS__);
}
/**
* {@inheritdoc}
*/
public function putOffExpiration(Key $key, $ttl)
{
// do nothing, the flock locks forever.
}
/**
* {@inheritdoc}
*/
public function exists(Key $key)
{
return $key->hasState(__CLASS__);
}
}

View File

@ -0,0 +1,73 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\NotSupportedException;
/**
* StoreInterface defines an interface to manipulate a lock store.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface StoreInterface
{
/**
* Stores the resource if it's not locked by someone else.
*
* @param Key $key key to lock
*
* @throws LockConflictedException
*/
public function save(Key $key);
/**
* Waits a key becomes free, then stores the resource.
*
* If the store does not support this feature it should throw a NotSupportedException.
*
* @param Key $key key to lock
*
* @throws LockConflictedException
* @throws NotSupportedException
*/
public function waitAndSave(Key $key);
/**
* Extends the ttl of a resource.
*
* If the store does not support this feature it should throw a NotSupportedException.
*
* @param Key $key key to lock
* @param float $ttl amount of second to keep the lock in the store
*
* @throws LockConflictedException
* @throws NotSupportedException
*/
public function putOffExpiration(Key $key, $ttl);
/**
* Removes a resource from the storage.
*
* @param Key $key key to remove
*/
public function delete(Key $key);
/**
* Returns whether or not the resource exists in the storage.
*
* @param Key $key key to remove
*
* @return bool
*/
public function exists(Key $key);
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Lock\Factory;
use Symfony\Component\Lock\LockInterface;
use Symfony\Component\Lock\StoreInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class FactoryTest extends TestCase
{
public function testCreateLock()
{
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$logger = $this->getMockBuilder(LoggerInterface::class)->getMock();
$factory = new Factory($store);
$factory->setLogger($logger);
$lock = $factory->createLock('foo');
$this->assertInstanceOf(LockInterface::class, $lock);
}
}

View File

@ -0,0 +1,156 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\Lock;
use Symfony\Component\Lock\StoreInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class LockTest extends TestCase
{
public function testAcquireNoBlocking()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store);
$store
->expects($this->once())
->method('save');
$this->assertTrue($lock->acquire(false));
}
public function testAcquireReturnsFalse()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store);
$store
->expects($this->once())
->method('save')
->willThrowException(new LockConflictedException());
$this->assertFalse($lock->acquire(false));
}
public function testAcquireBlocking()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store);
$store
->expects($this->never())
->method('save');
$store
->expects($this->once())
->method('waitAndSave');
$this->assertTrue($lock->acquire(true));
}
public function testAcquireSetsTtl()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store, 10);
$store
->expects($this->once())
->method('save');
$store
->expects($this->once())
->method('putOffExpiration')
->with($key, 10);
$lock->acquire();
}
public function testRefresh()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store, 10);
$store
->expects($this->once())
->method('putOffExpiration')
->with($key, 10);
$lock->refresh();
}
public function testIsAquired()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store, 10);
$store
->expects($this->once())
->method('exists')
->with($key)
->willReturn(true);
$this->assertTrue($lock->isAcquired());
}
public function testRelease()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store, 10);
$store
->expects($this->once())
->method('delete')
->with($key);
$store
->expects($this->once())
->method('exists')
->with($key)
->willReturn(false);
$lock->release();
}
/**
* @expectedException \Symfony\Component\Lock\Exception\LockReleasingException
*/
public function testReleaseThrowsExceptionIfNotWellDeleted()
{
$key = new Key(uniqid(__METHOD__, true));
$store = $this->getMockBuilder(StoreInterface::class)->getMock();
$lock = new Lock($key, $store, 10);
$store
->expects($this->once())
->method('delete')
->with($key);
$store
->expects($this->once())
->method('exists')
->with($key)
->willReturn(true);
$lock->release();
}
}

View File

@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Quorum;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Lock\Quorum\ConsensusStrategy;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class ConsensusStrategyTest extends TestCase
{
/** @var ConsensusStrategy */
private $quorum;
public function setup()
{
$this->quorum = new ConsensusStrategy();
}
public function provideMetResults()
{
// success, failure, total, isMet
yield array(3, 0, 3, true);
yield array(2, 1, 3, true);
yield array(2, 0, 3, true);
yield array(1, 2, 3, false);
yield array(1, 1, 3, false);
yield array(1, 0, 3, false);
yield array(0, 3, 3, false);
yield array(0, 2, 3, false);
yield array(0, 1, 3, false);
yield array(0, 0, 3, false);
yield array(2, 0, 2, true);
yield array(1, 1, 2, false);
yield array(1, 0, 2, false);
yield array(0, 2, 2, false);
yield array(0, 1, 2, false);
yield array(0, 0, 2, false);
}
public function provideIndeterminate()
{
// success, failure, total, canBeMet
yield array(3, 0, 3, true);
yield array(2, 1, 3, true);
yield array(2, 0, 3, true);
yield array(1, 2, 3, false);
yield array(1, 1, 3, true);
yield array(1, 0, 3, true);
yield array(0, 3, 3, false);
yield array(0, 2, 3, false);
yield array(0, 1, 3, true);
yield array(0, 0, 3, true);
yield array(2, 0, 2, true);
yield array(1, 1, 2, false);
yield array(1, 0, 2, true);
yield array(0, 2, 2, false);
yield array(0, 1, 2, false);
yield array(0, 0, 2, true);
}
/**
* @dataProvider provideMetResults
*/
public function testMet($success, $failure, $total, $isMet)
{
$this->assertSame($isMet, $this->quorum->isMet($success, $total));
}
/**
* @dataProvider provideIndeterminate
*/
public function canBeMet($success, $failure, $total, $isMet)
{
$this->assertSame($isMet, $this->quorum->canBeMet($failure, $total));
}
}

View File

@ -0,0 +1,89 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Quorum;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Lock\Quorum\UnanimousStrategy;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class UnanimousStrategyTest extends TestCase
{
/** @var UnanimousStrategy */
private $quorum;
public function setup()
{
$this->quorum = new UnanimousStrategy();
}
public function provideMetResults()
{
// success, failure, total, isMet
yield array(3, 0, 3, true);
yield array(2, 1, 3, false);
yield array(2, 0, 3, false);
yield array(1, 2, 3, false);
yield array(1, 1, 3, false);
yield array(1, 0, 3, false);
yield array(0, 3, 3, false);
yield array(0, 2, 3, false);
yield array(0, 1, 3, false);
yield array(0, 0, 3, false);
yield array(2, 0, 2, true);
yield array(1, 1, 2, false);
yield array(1, 0, 2, false);
yield array(0, 2, 2, false);
yield array(0, 1, 2, false);
yield array(0, 0, 2, false);
}
public function provideIndeterminate()
{
// success, failure, total, canBeMet
yield array(3, 0, 3, true);
yield array(2, 1, 3, false);
yield array(2, 0, 3, true);
yield array(1, 2, 3, false);
yield array(1, 1, 3, false);
yield array(1, 0, 3, true);
yield array(0, 3, 3, false);
yield array(0, 2, 3, false);
yield array(0, 1, 3, false);
yield array(0, 0, 3, true);
yield array(2, 0, 2, true);
yield array(1, 1, 2, false);
yield array(1, 0, 2, true);
yield array(0, 2, 2, false);
yield array(0, 1, 2, false);
yield array(0, 0, 2, true);
}
/**
* @dataProvider provideMetResults
*/
public function testMet($success, $failure, $total, $isMet)
{
$this->assertSame($isMet, $this->quorum->isMet($success, $total));
}
/**
* @dataProvider provideIndeterminate
*/
public function canBeMet($success, $failure, $total, $isMet)
{
$this->assertSame($isMet, $this->quorum->canBeMet($failure, $total));
}
}

View File

@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Store\RedisStore;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
abstract class AbstractRedisStoreTest extends AbstractStoreTest
{
use ExpiringStoreTestTrait;
/**
* {@inheritdoc}
*/
protected function getClockDelay()
{
return 250000;
}
/**
* Return a RedisConnection.
*
* @return \Redis|\RedisArray|\RedisCluster|\Predis\Client
*/
abstract protected function getRedisConnection();
/**
* {@inheritdoc}
*/
public function getStore()
{
return new RedisStore($this->getRedisConnection());
}
}

View File

@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
abstract class AbstractStoreTest extends TestCase
{
/**
* @return StoreInterface;
*/
abstract protected function getStore();
public function testSave()
{
$store = $this->getStore();
$key = new Key(uniqid(__METHOD__, true));
$this->assertFalse($store->exists($key));
$store->save($key);
$this->assertTrue($store->exists($key));
$store->delete($key);
$this->assertFalse($store->exists($key));
}
public function testSaveWithDifferentResources()
{
$store = $this->getStore();
$key1 = new Key(uniqid(__METHOD__, true));
$key2 = new Key(uniqid(__METHOD__, true));
$store->save($key1);
$this->assertTrue($store->exists($key1));
$this->assertFalse($store->exists($key2));
$store->save($key2);
$this->assertTrue($store->exists($key1));
$this->assertTrue($store->exists($key2));
$store->delete($key1);
$this->assertFalse($store->exists($key1));
$store->delete($key2);
$this->assertFalse($store->exists($key2));
}
public function testSaveWithDifferentKeysOnSameResources()
{
$store = $this->getStore();
$resource = uniqid(__METHOD__, true);
$key1 = new Key($resource);
$key2 = new Key($resource);
$store->save($key1);
$this->assertTrue($store->exists($key1));
$this->assertFalse($store->exists($key2));
try {
$store->save($key2);
throw new \Exception('The store shouldn\'t save the second key');
} catch (LockConflictedException $e) {
}
// The failure of previous attempt should not impact the state of current locks
$this->assertTrue($store->exists($key1));
$this->assertFalse($store->exists($key2));
$store->delete($key1);
$this->assertFalse($store->exists($key1));
$this->assertFalse($store->exists($key2));
$store->save($key2);
$this->assertFalse($store->exists($key1));
$this->assertTrue($store->exists($key2));
$store->delete($key2);
$this->assertFalse($store->exists($key1));
$this->assertFalse($store->exists($key2));
}
public function testSaveTwice()
{
$store = $this->getStore();
$resource = uniqid(__METHOD__, true);
$key = new Key($resource);
$store->save($key);
$store->save($key);
// just asserts it don't throw an exception
$this->addToAssertionCount(1);
$store->delete($key);
}
}

View File

@ -0,0 +1,95 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
trait BlockingStoreTestTrait
{
/**
* @see AbstractStoreTest::getStore()
*/
abstract protected function getStore();
/**
* Tests blocking locks thanks to pcntl.
*
* This test is time sensible: the $clockDelay could be adjust.
*
* @requires extension pcntl
*/
public function testBlockingLocks()
{
// Amount a microsecond used to order async actions
$clockDelay = 30000;
if (PHP_VERSION_ID < 50600 || defined('HHVM_VERSION_ID')) {
$this->markTestSkipped('The PHP engine does not keep resource in child forks');
return;
}
/** @var StoreInterface $store */
$store = $this->getStore();
$key = new Key(uniqid(__METHOD__, true));
if ($childPID1 = pcntl_fork()) {
if ($childPID2 = pcntl_fork()) {
if ($childPID3 = pcntl_fork()) {
// This is the parent, wait for the end of child process to assert their results
pcntl_waitpid($childPID1, $status1);
pcntl_waitpid($childPID2, $status2);
pcntl_waitpid($childPID3, $status3);
$this->assertSame(0, pcntl_wexitstatus($status1));
$this->assertSame(0, pcntl_wexitstatus($status2));
$this->assertSame(3, pcntl_wexitstatus($status3));
} else {
usleep(2 * $clockDelay);
try {
// This call should failed given the lock should already by acquired by the child #1
$store->save($key);
exit(0);
} catch (\Exception $e) {
exit(3);
}
}
} else {
usleep(1 * $clockDelay);
try {
// This call should be block by the child #1
$store->waitAndSave($key);
$this->assertTrue($store->exists($key));
$store->delete($key);
exit(0);
} catch (\Exception $e) {
exit(2);
}
}
} else {
try {
$store->save($key);
// Wait 3 ClockDelay to let other child to be initialized
usleep(3 * $clockDelay);
$store->delete($key);
exit(0);
} catch (\Exception $e) {
exit(1);
}
}
}
}

View File

@ -0,0 +1,356 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\Quorum\UnanimousStrategy;
use Symfony\Component\Lock\QuorumInterface;
use Symfony\Component\Lock\Store\CombinedStore;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\Lock\StoreInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CombinedStoreTest extends AbstractStoreTest
{
use ExpiringStoreTestTrait;
/**
* {@inheritdoc}
*/
protected function getClockDelay()
{
return 250000;
}
/**
* {@inheritdoc}
*/
public function getStore()
{
$redis = new \Predis\Client('tcp://'.getenv('REDIS_HOST').':6379');
try {
$redis->connect();
} catch (\Exception $e) {
self::markTestSkipped($e->getMessage());
}
return new CombinedStore(array(new RedisStore($redis)), new UnanimousStrategy());
}
/** @var \PHPUnit_Framework_MockObject_MockObject */
private $quorum;
/** @var \PHPUnit_Framework_MockObject_MockObject */
private $store1;
/** @var \PHPUnit_Framework_MockObject_MockObject */
private $store2;
/** @var CombinedStore */
private $store;
public function setup()
{
$this->quorum = $this->getMockBuilder(QuorumInterface::class)->getMock();
$this->store1 = $this->getMockBuilder(StoreInterface::class)->getMock();
$this->store2 = $this->getMockBuilder(StoreInterface::class)->getMock();
$this->store = new CombinedStore(array($this->store1, $this->store2), $this->quorum);
}
/**
* @expectedException \Symfony\Component\Lock\Exception\LockConflictedException
*/
public function testSaveThrowsExceptionOnFailure()
{
$key = new Key(uniqid(__METHOD__, true));
$this->store1
->expects($this->once())
->method('save')
->with($key)
->willThrowException(new LockConflictedException());
$this->store2
->expects($this->once())
->method('save')
->with($key)
->willThrowException(new LockConflictedException());
$this->quorum
->expects($this->any())
->method('canBeMet')
->willReturn(true);
$this->quorum
->expects($this->any())
->method('isMet')
->willReturn(false);
$this->store->save($key);
}
public function testSaveCleanupOnFailure()
{
$key = new Key(uniqid(__METHOD__, true));
$this->store1
->expects($this->once())
->method('save')
->with($key)
->willThrowException(new LockConflictedException());
$this->store2
->expects($this->once())
->method('save')
->with($key)
->willThrowException(new LockConflictedException());
$this->store1
->expects($this->once())
->method('delete');
$this->store2
->expects($this->once())
->method('delete');
$this->quorum
->expects($this->any())
->method('canBeMet')
->willReturn(true);
$this->quorum
->expects($this->any())
->method('isMet')
->willReturn(false);
try {
$this->store->save($key);
} catch (LockConflictedException $e) {
// Catch the exception given this is not what we want to assert in this tests
}
}
public function testSaveAbortWhenQuorumCantBeMet()
{
$key = new Key(uniqid(__METHOD__, true));
$this->store1
->expects($this->once())
->method('save')
->with($key)
->willThrowException(new LockConflictedException());
$this->store2
->expects($this->never())
->method('save');
$this->quorum
->expects($this->once())
->method('canBeMet')
->willReturn(false);
$this->quorum
->expects($this->any())
->method('isMet')
->willReturn(false);
try {
$this->store->save($key);
} catch (LockConflictedException $e) {
// Catch the exception given this is not what we want to assert in this tests
}
}
/**
* @expectedException \Symfony\Component\Lock\Exception\LockConflictedException
*/
public function testputOffExpirationThrowsExceptionOnFailure()
{
$key = new Key(uniqid(__METHOD__, true));
$ttl = random_int(1, 10);
$this->store1
->expects($this->once())
->method('putOffExpiration')
->with($key, $ttl)
->willThrowException(new LockConflictedException());
$this->store2
->expects($this->once())
->method('putOffExpiration')
->with($key, $ttl)
->willThrowException(new LockConflictedException());
$this->quorum
->expects($this->any())
->method('canBeMet')
->willReturn(true);
$this->quorum
->expects($this->any())
->method('isMet')
->willReturn(false);
$this->store->putOffExpiration($key, $ttl);
}
public function testputOffExpirationCleanupOnFailure()
{
$key = new Key(uniqid(__METHOD__, true));
$ttl = random_int(1, 10);
$this->store1
->expects($this->once())
->method('putOffExpiration')
->with($key, $ttl)
->willThrowException(new LockConflictedException());
$this->store2
->expects($this->once())
->method('putOffExpiration')
->with($key, $ttl)
->willThrowException(new LockConflictedException());
$this->store1
->expects($this->once())
->method('delete');
$this->store2
->expects($this->once())
->method('delete');
$this->quorum
->expects($this->any())
->method('canBeMet')
->willReturn(true);
$this->quorum
->expects($this->any())
->method('isMet')
->willReturn(false);
try {
$this->store->putOffExpiration($key, $ttl);
} catch (LockConflictedException $e) {
// Catch the exception given this is not what we want to assert in this tests
}
}
public function testputOffExpirationAbortWhenQuorumCantBeMet()
{
$key = new Key(uniqid(__METHOD__, true));
$ttl = random_int(1, 10);
$this->store1
->expects($this->once())
->method('putOffExpiration')
->with($key, $ttl)
->willThrowException(new LockConflictedException());
$this->store2
->expects($this->never())
->method('putOffExpiration');
$this->quorum
->expects($this->once())
->method('canBeMet')
->willReturn(false);
$this->quorum
->expects($this->any())
->method('isMet')
->willReturn(false);
try {
$this->store->putOffExpiration($key, $ttl);
} catch (LockConflictedException $e) {
// Catch the exception given this is not what we want to assert in this tests
}
}
public function testPutOffExpirationIgnoreNonExpiringStorage()
{
$store1 = $this->getMockBuilder(StoreInterface::class)->getMock();
$store2 = $this->getMockBuilder(StoreInterface::class)->getMock();
$store = new CombinedStore(array($store1, $store2), $this->quorum);
$key = new Key(uniqid(__METHOD__, true));
$ttl = random_int(1, 10);
$this->quorum
->expects($this->any())
->method('canBeMet')
->willReturn(true);
$this->quorum
->expects($this->once())
->method('isMet')
->with(2, 2)
->willReturn(true);
$store->putOffExpiration($key, $ttl);
}
public function testExistsDontAskToEveryBody()
{
$key = new Key(uniqid(__METHOD__, true));
$this->store1
->expects($this->any())
->method('exists')
->with($key)
->willReturn(false);
$this->store2
->expects($this->never())
->method('exists');
$this->quorum
->expects($this->any())
->method('canBeMet')
->willReturn(true);
$this->quorum
->expects($this->once())
->method('isMet')
->willReturn(true);
$this->assertTrue($this->store->exists($key));
}
public function testExistsAbortWhenQuorumCantBeMet()
{
$key = new Key(uniqid(__METHOD__, true));
$this->store1
->expects($this->any())
->method('exists')
->with($key)
->willReturn(false);
$this->store2
->expects($this->never())
->method('exists');
$this->quorum
->expects($this->once())
->method('canBeMet')
->willReturn(false);
$this->quorum
->expects($this->once())
->method('isMet')
->willReturn(false);
$this->assertFalse($this->store->exists($key));
}
public function testDeleteDontStopOnFailure()
{
$key = new Key(uniqid(__METHOD__, true));
$this->store1
->expects($this->once())
->method('delete')
->with($key)
->willThrowException(new \Exception());
$this->store2
->expects($this->once())
->method('delete')
->with($key);
$this->store->delete($key);
}
}

View File

@ -0,0 +1,78 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
trait ExpiringStoreTestTrait
{
/**
* Amount a microsecond used to order async actions
*
* @return int
*/
abstract protected function getClockDelay();
/**
* @see AbstractStoreTest::getStore()
*/
abstract protected function getStore();
/**
* Tests the store automatically delete the key when it expire.
*
* This test is time sensible: the $clockDelay could be adjust.
*/
public function testExpiration()
{
$key = new Key(uniqid(__METHOD__, true));
$clockDelay = $this->getClockDelay();
/** @var StoreInterface $store */
$store = $this->getStore();
$store->save($key);
$store->putOffExpiration($key, $clockDelay / 1000000);
$this->assertTrue($store->exists($key));
usleep(2 * $clockDelay);
$this->assertFalse($store->exists($key));
}
/**
* Tests the refresh can push the limits to the expiration.
*
* This test is time sensible: the $clockDelay could be adjust.
*/
public function testRefreshLock()
{
// Amount a microsecond used to order async actions
$clockDelay = $this->getClockDelay();
// Amount a microsecond used to order async actions
$key = new Key(uniqid(__METHOD__, true));
/** @var StoreInterface $store */
$store = $this->getStore();
$store->save($key);
$store->putOffExpiration($key, 1.0 * $clockDelay / 1000000);
$this->assertTrue($store->exists($key));
usleep(1.5 * $clockDelay);
$this->assertFalse($store->exists($key));
}
}

View File

@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\Store\FlockStore;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class FlockStoreTest extends AbstractStoreTest
{
use BlockingStoreTestTrait;
/**
* {@inheritdoc}
*/
protected function getStore()
{
return new FlockStore(sys_get_temp_dir());
}
/**
* @expectedException \Symfony\Component\Lock\Exception\InvalidArgumentException
* @expectedExceptionMessage The directory "/a/b/c/d/e" is not writable.
*/
public function testConstructWhenRepositoryDoesNotExist()
{
if (!getenv('USER') || 'root' === getenv('USER')) {
$this->markTestSkipped('This test will fail if run under superuser');
}
new FlockStore('/a/b/c/d/e');
}
/**
* @expectedException \Symfony\Component\Lock\Exception\InvalidArgumentException
* @expectedExceptionMessage The directory "/" is not writable.
*/
public function testConstructWhenRepositoryIsNotWriteable()
{
if (!getenv('USER') || 'root' === getenv('USER')) {
$this->markTestSkipped('This test will fail if run under superuser');
}
new FlockStore('/');
}
public function testSaveSanitizeName()
{
$store = $this->getStore();
$key = new Key('<?php echo "% hello word ! %" ?>');
$file = sprintf(
'%s/sf.-php-echo-hello-word-.4b3d9d0d27ddef3a78a64685dda3a963e478659a9e5240feaf7b4173a8f28d5f.lock',
sys_get_temp_dir()
);
// ensure the file does not exist before the store
@unlink($file);
$store->save($key);
$this->assertFileExists($file);
$store->delete($key);
}
}

View File

@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Store\MemcachedStore;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*
* @requires extension memcached
*/
class MemcachedStoreTest extends AbstractStoreTest
{
use ExpiringStoreTestTrait;
public static function setupBeforeClass()
{
$memcached = new \Memcached();
$memcached->addServer(getenv('MEMCACHED_HOST'), 11211);
if (false === $memcached->getStats()) {
self::markTestSkipped('Unable to connect to the memcache host');
}
}
/**
* {@inheritdoc}
*/
protected function getClockDelay()
{
return 1000000;
}
/**
* {@inheritdoc}
*/
public function getStore()
{
$memcached = new \Memcached();
$memcached->addServer(getenv('MEMCACHED_HOST'), 11211);
return new MemcachedStore($memcached);
}
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class PredisStoreTest extends AbstractRedisStoreTest
{
public static function setupBeforeClass()
{
$redis = new \Predis\Client('tcp://'.getenv('REDIS_HOST').':6379');
try {
$redis->connect();
} catch (\Exception $e) {
self::markTestSkipped($e->getMessage());
}
}
protected function getRedisConnection()
{
$redis = new \Predis\Client('tcp://'.getenv('REDIS_HOST').':6379');
$redis->connect();
return $redis;
}
}

View File

@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*
* @requires extension redis
*/
class RedisArrayStoreTest extends AbstractRedisStoreTest
{
public static function setupBeforeClass()
{
if (!class_exists('RedisArray')) {
self::markTestSkipped('The RedisArray class is required.');
}
if (!@((new \Redis())->connect(getenv('REDIS_HOST')))) {
$e = error_get_last();
self::markTestSkipped($e['message']);
}
}
protected function getRedisConnection()
{
$redis = new \RedisArray(array(getenv('REDIS_HOST')));
return $redis;
}
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*
* @requires extension redis
*/
class RedisStoreTest extends AbstractRedisStoreTest
{
public static function setupBeforeClass()
{
if (!@((new \Redis())->connect(getenv('REDIS_HOST')))) {
$e = error_get_last();
self::markTestSkipped($e['message']);
}
}
protected function getRedisConnection()
{
$redis = new \Redis();
$redis->connect(getenv('REDIS_HOST'));
return $redis;
}
}

View File

@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Store\RedisStore;
use Symfony\Component\Lock\Store\RetryTillSaveStore;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class RetryTillSaveStoreTest extends AbstractStoreTest
{
use BlockingStoreTestTrait;
public function getStore()
{
$redis = new \Predis\Client('tcp://'.getenv('REDIS_HOST').':6379');
try {
$redis->connect();
} catch (\Exception $e) {
self::markTestSkipped($e->getMessage());
}
return new RetryTillSaveStore(new RedisStore($redis), 100, 100);
}
}

View File

@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Symfony\Component\Lock\Tests\Store;
use Symfony\Component\Lock\Store\SemaphoreStore;
/**
* @author Jérémy Derussé <jeremy@derusse.com>
*
* @require
*/
class SemaphoreStoreTest extends AbstractStoreTest
{
use BlockingStoreTestTrait;
/**
* {@inheritdoc}
*/
protected function getStore()
{
if (PHP_VERSION_ID < 50601) {
$this->markTestSkipped('Non blocking semaphore are supported by PHP version greater or equals than 5.6.1');
}
return new SemaphoreStore();
}
}

View File

@ -0,0 +1,38 @@
{
"name": "symfony/lock",
"type": "library",
"description": "Symfony Lock Component",
"keywords": ["locking", "redlock", "mutex", "semaphore", "flock", "cas"],
"homepage": "https://symfony.com",
"license": "MIT",
"authors": [
{
"name": "Jérémy Derussé",
"email": "jeremy@derusse.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"require": {
"php": ">=5.5.9",
"symfony/polyfill-php70": "~1.0",
"psr/log": "~1.0"
},
"require-dev": {
"predis/predis": "~1.0"
},
"autoload": {
"psr-4": { "Symfony\\Component\\Lock\\": "" },
"exclude-from-classmap": [
"/Tests/"
]
},
"minimum-stability": "dev",
"extra": {
"branch-alias": {
"dev-master": "3.3-dev"
}
}
}

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="http://schema.phpunit.de/4.1/phpunit.xsd"
backupGlobals="false"
colors="true"
bootstrap="vendor/autoload.php"
>
<php>
<ini name="error_reporting" value="-1" />
<env name="REDIS_HOST" value="redis.docker" />
<env name="MEMCACHED_HOST" value="memcached.docker" />
</php>
<testsuites>
<testsuite name="Symfony Lock Component Test Suite">
<directory>./Tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory>./</directory>
<exclude>
<directory>./Tests</directory>
<directory>./vendor</directory>
</exclude>
</whitelist>
</filter>
</phpunit>