From 018e0fc330146276922a720b8acea84752fba923 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9my=20Deruss=C3=A9?= Date: Wed, 28 Dec 2016 21:33:18 +0100 Subject: [PATCH] [Lock] Create a lock component --- composer.json | 1 + src/Symfony/Component/Lock/.gitignore | 3 + src/Symfony/Component/Lock/CHANGELOG.md | 7 + .../Lock/Exception/ExceptionInterface.php | 21 ++ .../Exception/InvalidArgumentException.php | 19 + .../Lock/Exception/LockAcquiringException.php | 21 ++ .../Exception/LockConflictedException.php | 21 ++ .../Lock/Exception/LockReleasingException.php | 21 ++ .../Lock/Exception/LockStorageException.php | 21 ++ .../Lock/Exception/NotSupportedException.php | 21 ++ src/Symfony/Component/Lock/Factory.php | 51 +++ src/Symfony/Component/Lock/Key.php | 73 ++++ src/Symfony/Component/Lock/LICENSE | 19 + src/Symfony/Component/Lock/Lock.php | 123 ++++++ src/Symfony/Component/Lock/LockInterface.php | 59 +++ .../Lock/Quorum/ConsensusStrategy.php | 38 ++ .../Lock/Quorum/UnanimousStrategy.php | 38 ++ .../Component/Lock/QuorumInterface.php | 43 +++ src/Symfony/Component/Lock/README.md | 11 + .../Component/Lock/Store/CombinedStore.php | 174 +++++++++ .../Component/Lock/Store/FlockStore.php | 138 +++++++ .../Component/Lock/Store/MemcachedStore.php | 179 +++++++++ .../Component/Lock/Store/RedisStore.php | 153 ++++++++ .../Lock/Store/RetryTillSaveStore.php | 102 +++++ .../Component/Lock/Store/SemaphoreStore.php | 112 ++++++ src/Symfony/Component/Lock/StoreInterface.php | 73 ++++ .../Component/Lock/Tests/FactoryTest.php | 36 ++ src/Symfony/Component/Lock/Tests/LockTest.php | 156 ++++++++ .../Tests/Quorum/ConsensusStrategyTest.php | 89 +++++ .../Tests/Quorum/UnanimousStrategyTest.php | 89 +++++ .../Tests/Store/AbstractRedisStoreTest.php | 45 +++ .../Lock/Tests/Store/AbstractStoreTest.php | 112 ++++++ .../Tests/Store/BlockingStoreTestTrait.php | 95 +++++ .../Lock/Tests/Store/CombinedStoreTest.php | 356 ++++++++++++++++++ .../Tests/Store/ExpiringStoreTestTrait.php | 78 ++++ .../Lock/Tests/Store/FlockStoreTest.php | 77 ++++ .../Lock/Tests/Store/MemcachedStoreTest.php | 52 +++ .../Lock/Tests/Store/PredisStoreTest.php | 36 ++ .../Lock/Tests/Store/RedisArrayStoreTest.php | 38 ++ .../Lock/Tests/Store/RedisStoreTest.php | 36 ++ .../Tests/Store/RetryTillSaveStoreTest.php | 35 ++ .../Lock/Tests/Store/SemaphoreStoreTest.php | 36 ++ src/Symfony/Component/Lock/composer.json | 38 ++ src/Symfony/Component/Lock/phpunit.xml.dist | 30 ++ 44 files changed, 2976 insertions(+) create mode 100644 src/Symfony/Component/Lock/.gitignore create mode 100644 src/Symfony/Component/Lock/CHANGELOG.md create mode 100644 src/Symfony/Component/Lock/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/Lock/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/Lock/Exception/LockAcquiringException.php create mode 100644 src/Symfony/Component/Lock/Exception/LockConflictedException.php create mode 100644 src/Symfony/Component/Lock/Exception/LockReleasingException.php create mode 100644 src/Symfony/Component/Lock/Exception/LockStorageException.php create mode 100644 src/Symfony/Component/Lock/Exception/NotSupportedException.php create mode 100644 src/Symfony/Component/Lock/Factory.php create mode 100644 src/Symfony/Component/Lock/Key.php create mode 100644 src/Symfony/Component/Lock/LICENSE create mode 100644 src/Symfony/Component/Lock/Lock.php create mode 100644 src/Symfony/Component/Lock/LockInterface.php create mode 100644 src/Symfony/Component/Lock/Quorum/ConsensusStrategy.php create mode 100644 src/Symfony/Component/Lock/Quorum/UnanimousStrategy.php create mode 100644 src/Symfony/Component/Lock/QuorumInterface.php create mode 100644 src/Symfony/Component/Lock/README.md create mode 100644 src/Symfony/Component/Lock/Store/CombinedStore.php create mode 100644 src/Symfony/Component/Lock/Store/FlockStore.php create mode 100644 src/Symfony/Component/Lock/Store/MemcachedStore.php create mode 100644 src/Symfony/Component/Lock/Store/RedisStore.php create mode 100644 src/Symfony/Component/Lock/Store/RetryTillSaveStore.php create mode 100644 src/Symfony/Component/Lock/Store/SemaphoreStore.php create mode 100644 src/Symfony/Component/Lock/StoreInterface.php create mode 100644 src/Symfony/Component/Lock/Tests/FactoryTest.php create mode 100644 src/Symfony/Component/Lock/Tests/LockTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Quorum/ConsensusStrategyTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Quorum/UnanimousStrategyTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/AbstractRedisStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/AbstractStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/CombinedStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/PredisStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/RedisArrayStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/RedisStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/RetryTillSaveStoreTest.php create mode 100644 src/Symfony/Component/Lock/Tests/Store/SemaphoreStoreTest.php create mode 100644 src/Symfony/Component/Lock/composer.json create mode 100644 src/Symfony/Component/Lock/phpunit.xml.dist diff --git a/composer.json b/composer.json index 266e517918..88e403d378 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/src/Symfony/Component/Lock/.gitignore b/src/Symfony/Component/Lock/.gitignore new file mode 100644 index 0000000000..5414c2c655 --- /dev/null +++ b/src/Symfony/Component/Lock/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/Lock/CHANGELOG.md b/src/Symfony/Component/Lock/CHANGELOG.md new file mode 100644 index 0000000000..2204282c26 --- /dev/null +++ b/src/Symfony/Component/Lock/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +3.3.0 +----- + + * added the component diff --git a/src/Symfony/Component/Lock/Exception/ExceptionInterface.php b/src/Symfony/Component/Lock/Exception/ExceptionInterface.php new file mode 100644 index 0000000000..0d41958474 --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/ExceptionInterface.php @@ -0,0 +1,21 @@ + + * + * 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é + */ +interface ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Exception/InvalidArgumentException.php b/src/Symfony/Component/Lock/Exception/InvalidArgumentException.php new file mode 100644 index 0000000000..f2f74b37ce --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/InvalidArgumentException.php @@ -0,0 +1,19 @@ + + * + * 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é + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Exception/LockAcquiringException.php b/src/Symfony/Component/Lock/Exception/LockAcquiringException.php new file mode 100644 index 0000000000..e6756aec14 --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/LockAcquiringException.php @@ -0,0 +1,21 @@ + + * + * 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é + */ +class LockAcquiringException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Exception/LockConflictedException.php b/src/Symfony/Component/Lock/Exception/LockConflictedException.php new file mode 100644 index 0000000000..8fcd6a836d --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/LockConflictedException.php @@ -0,0 +1,21 @@ + + * + * 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é + */ +class LockConflictedException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Exception/LockReleasingException.php b/src/Symfony/Component/Lock/Exception/LockReleasingException.php new file mode 100644 index 0000000000..56eedde79e --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/LockReleasingException.php @@ -0,0 +1,21 @@ + + * + * 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é + */ +class LockReleasingException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Exception/LockStorageException.php b/src/Symfony/Component/Lock/Exception/LockStorageException.php new file mode 100644 index 0000000000..8c393fc1bd --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/LockStorageException.php @@ -0,0 +1,21 @@ + + * + * 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é + */ +class LockStorageException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Exception/NotSupportedException.php b/src/Symfony/Component/Lock/Exception/NotSupportedException.php new file mode 100644 index 0000000000..c9a7f013c1 --- /dev/null +++ b/src/Symfony/Component/Lock/Exception/NotSupportedException.php @@ -0,0 +1,21 @@ + + * + * 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é + */ +class NotSupportedException extends \LogicException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Lock/Factory.php b/src/Symfony/Component/Lock/Factory.php new file mode 100644 index 0000000000..c050145f90 --- /dev/null +++ b/src/Symfony/Component/Lock/Factory.php @@ -0,0 +1,51 @@ + + * + * 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é + */ +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; + } +} diff --git a/src/Symfony/Component/Lock/Key.php b/src/Symfony/Component/Lock/Key.php new file mode 100644 index 0000000000..5b53ae9b6b --- /dev/null +++ b/src/Symfony/Component/Lock/Key.php @@ -0,0 +1,73 @@ + + * + * 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é + */ +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]; + } +} diff --git a/src/Symfony/Component/Lock/LICENSE b/src/Symfony/Component/Lock/LICENSE new file mode 100644 index 0000000000..ce39894f6a --- /dev/null +++ b/src/Symfony/Component/Lock/LICENSE @@ -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. diff --git a/src/Symfony/Component/Lock/Lock.php b/src/Symfony/Component/Lock/Lock.php new file mode 100644 index 0000000000..06ceec44e2 --- /dev/null +++ b/src/Symfony/Component/Lock/Lock.php @@ -0,0 +1,123 @@ + + * + * 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é + */ +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(); + } + } +} diff --git a/src/Symfony/Component/Lock/LockInterface.php b/src/Symfony/Component/Lock/LockInterface.php new file mode 100644 index 0000000000..ab05d7a8f4 --- /dev/null +++ b/src/Symfony/Component/Lock/LockInterface.php @@ -0,0 +1,59 @@ + + * + * 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é + */ +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(); +} diff --git a/src/Symfony/Component/Lock/Quorum/ConsensusStrategy.php b/src/Symfony/Component/Lock/Quorum/ConsensusStrategy.php new file mode 100644 index 0000000000..a4b193d05d --- /dev/null +++ b/src/Symfony/Component/Lock/Quorum/ConsensusStrategy.php @@ -0,0 +1,38 @@ + + * + * 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é + */ +class ConsensusStrategy implements QuorumInterface +{ + /** + * {@inheritdoc} + */ + public function isMet($numberOfSuccess, $numberOfItems) + { + return $numberOfSuccess > ($numberOfItems / 2); + } + + /** + * {@inheritdoc} + */ + public function canBeMet($numberOfFailure, $numberOfItems) + { + return $numberOfFailure < ($numberOfItems / 2); + } +} diff --git a/src/Symfony/Component/Lock/Quorum/UnanimousStrategy.php b/src/Symfony/Component/Lock/Quorum/UnanimousStrategy.php new file mode 100644 index 0000000000..487a2dd1df --- /dev/null +++ b/src/Symfony/Component/Lock/Quorum/UnanimousStrategy.php @@ -0,0 +1,38 @@ + + * + * 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é + */ +class UnanimousStrategy implements QuorumInterface +{ + /** + * {@inheritdoc} + */ + public function isMet($numberOfSuccess, $numberOfItems) + { + return $numberOfSuccess === $numberOfItems; + } + + /** + * {@inheritdoc} + */ + public function canBeMet($numberOfFailure, $numberOfItems) + { + return $numberOfFailure === 0; + } +} diff --git a/src/Symfony/Component/Lock/QuorumInterface.php b/src/Symfony/Component/Lock/QuorumInterface.php new file mode 100644 index 0000000000..5dddfaa973 --- /dev/null +++ b/src/Symfony/Component/Lock/QuorumInterface.php @@ -0,0 +1,43 @@ + + * + * 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é + */ +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); +} diff --git a/src/Symfony/Component/Lock/README.md b/src/Symfony/Component/Lock/README.md new file mode 100644 index 0000000000..0be0bfd49d --- /dev/null +++ b/src/Symfony/Component/Lock/README.md @@ -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) diff --git a/src/Symfony/Component/Lock/Store/CombinedStore.php b/src/Symfony/Component/Lock/Store/CombinedStore.php new file mode 100644 index 0000000000..7122b9019e --- /dev/null +++ b/src/Symfony/Component/Lock/Store/CombinedStore.php @@ -0,0 +1,174 @@ + + * + * 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é + */ +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; + } +} diff --git a/src/Symfony/Component/Lock/Store/FlockStore.php b/src/Symfony/Component/Lock/Store/FlockStore.php new file mode 100644 index 0000000000..6bd28c83a4 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/FlockStore.php @@ -0,0 +1,138 @@ + + * + * 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é + * @author Grégoire Pineau + * @author Romain Neutron + * @author Nicolas Grekas + */ +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__); + } +} diff --git a/src/Symfony/Component/Lock/Store/MemcachedStore.php b/src/Symfony/Component/Lock/Store/MemcachedStore.php new file mode 100644 index 0000000000..a1e31ee633 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/MemcachedStore.php @@ -0,0 +1,179 @@ + + * + * 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é + */ +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); + } +} diff --git a/src/Symfony/Component/Lock/Store/RedisStore.php b/src/Symfony/Component/Lock/Store/RedisStore.php new file mode 100644 index 0000000000..8674a1a142 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/RedisStore.php @@ -0,0 +1,153 @@ + + * + * 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é + */ +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__); + } +} diff --git a/src/Symfony/Component/Lock/Store/RetryTillSaveStore.php b/src/Symfony/Component/Lock/Store/RetryTillSaveStore.php new file mode 100644 index 0000000000..dfc3b26668 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/RetryTillSaveStore.php @@ -0,0 +1,102 @@ + + * + * 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é + */ +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); + } +} diff --git a/src/Symfony/Component/Lock/Store/SemaphoreStore.php b/src/Symfony/Component/Lock/Store/SemaphoreStore.php new file mode 100644 index 0000000000..986c33b6e9 --- /dev/null +++ b/src/Symfony/Component/Lock/Store/SemaphoreStore.php @@ -0,0 +1,112 @@ + + * + * 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é + */ +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__); + } +} diff --git a/src/Symfony/Component/Lock/StoreInterface.php b/src/Symfony/Component/Lock/StoreInterface.php new file mode 100644 index 0000000000..428786b4c8 --- /dev/null +++ b/src/Symfony/Component/Lock/StoreInterface.php @@ -0,0 +1,73 @@ + + * + * 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é + */ +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); +} diff --git a/src/Symfony/Component/Lock/Tests/FactoryTest.php b/src/Symfony/Component/Lock/Tests/FactoryTest.php new file mode 100644 index 0000000000..d67949098c --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/FactoryTest.php @@ -0,0 +1,36 @@ + + * + * 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é + */ +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); + } +} diff --git a/src/Symfony/Component/Lock/Tests/LockTest.php b/src/Symfony/Component/Lock/Tests/LockTest.php new file mode 100644 index 0000000000..58dbdc5820 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/LockTest.php @@ -0,0 +1,156 @@ + + * + * 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é + */ +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(); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Quorum/ConsensusStrategyTest.php b/src/Symfony/Component/Lock/Tests/Quorum/ConsensusStrategyTest.php new file mode 100644 index 0000000000..1bebb184c7 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Quorum/ConsensusStrategyTest.php @@ -0,0 +1,89 @@ + + * + * 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é + */ +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)); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Quorum/UnanimousStrategyTest.php b/src/Symfony/Component/Lock/Tests/Quorum/UnanimousStrategyTest.php new file mode 100644 index 0000000000..ea5934aab2 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Quorum/UnanimousStrategyTest.php @@ -0,0 +1,89 @@ + + * + * 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é + */ +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)); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/AbstractRedisStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/AbstractRedisStoreTest.php new file mode 100644 index 0000000000..4b9c81bd8e --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/AbstractRedisStoreTest.php @@ -0,0 +1,45 @@ + + * + * 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é + */ +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()); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/AbstractStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/AbstractStoreTest.php new file mode 100644 index 0000000000..c0d758744c --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/AbstractStoreTest.php @@ -0,0 +1,112 @@ + + * + * 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é + */ +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); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php b/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php new file mode 100644 index 0000000000..e48a0fda3e --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/BlockingStoreTestTrait.php @@ -0,0 +1,95 @@ + + * + * 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é + */ +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); + } + } + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/CombinedStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/CombinedStoreTest.php new file mode 100644 index 0000000000..4670fdfde2 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/CombinedStoreTest.php @@ -0,0 +1,356 @@ + + * + * 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é + */ +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); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php b/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php new file mode 100644 index 0000000000..294ff97ce5 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/ExpiringStoreTestTrait.php @@ -0,0 +1,78 @@ + + * + * 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é + */ +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)); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php new file mode 100644 index 0000000000..17c59c440a --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/FlockStoreTest.php @@ -0,0 +1,77 @@ + + * + * 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é + */ +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(''); + + $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); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php new file mode 100644 index 0000000000..72615baae2 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/MemcachedStoreTest.php @@ -0,0 +1,52 @@ + + * + * 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é + * + * @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); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/PredisStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/PredisStoreTest.php new file mode 100644 index 0000000000..621affecb5 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/PredisStoreTest.php @@ -0,0 +1,36 @@ + + * + * 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é + */ +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; + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/RedisArrayStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/RedisArrayStoreTest.php new file mode 100644 index 0000000000..180da4618d --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/RedisArrayStoreTest.php @@ -0,0 +1,38 @@ + + * + * 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é + * + * @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; + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/RedisStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/RedisStoreTest.php new file mode 100644 index 0000000000..6c7d244107 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/RedisStoreTest.php @@ -0,0 +1,36 @@ + + * + * 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é + * + * @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; + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/RetryTillSaveStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/RetryTillSaveStoreTest.php new file mode 100644 index 0000000000..febd48f279 --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/RetryTillSaveStoreTest.php @@ -0,0 +1,35 @@ + + * + * 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é + */ +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); + } +} diff --git a/src/Symfony/Component/Lock/Tests/Store/SemaphoreStoreTest.php b/src/Symfony/Component/Lock/Tests/Store/SemaphoreStoreTest.php new file mode 100644 index 0000000000..681aa37afd --- /dev/null +++ b/src/Symfony/Component/Lock/Tests/Store/SemaphoreStoreTest.php @@ -0,0 +1,36 @@ + + * + * 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é + * + * @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(); + } +} diff --git a/src/Symfony/Component/Lock/composer.json b/src/Symfony/Component/Lock/composer.json new file mode 100644 index 0000000000..7c25a470f9 --- /dev/null +++ b/src/Symfony/Component/Lock/composer.json @@ -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" + } + } +} diff --git a/src/Symfony/Component/Lock/phpunit.xml.dist b/src/Symfony/Component/Lock/phpunit.xml.dist new file mode 100644 index 0000000000..e884d84d3e --- /dev/null +++ b/src/Symfony/Component/Lock/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + +