From 891285475ee4341e477cd78ecdf4c1c5adb4574f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Fri, 14 Feb 2020 19:27:06 +0100 Subject: [PATCH] [Semaphore] Added the component Few years ago, we have introduced the Lock component. This is a very nice component, but sometime it is not enough. Sometime you need semaphore. This is why I'm introducing this new component. From wikipedia: > In computer science, a semaphore is a variable or abstract data type used to control access to a common resource by multiple processes in a concurrent system such as a multitasking operating system. A semaphore is simply a variable. This variable is used to solve critical section problems and to achieve process synchronization in the multi processing environment. A trivial semaphore is a plain variable that is changed (for example, incremented or decremented, or toggled) depending on programmer-defined conditions. This new component is more than a variable. This is an abstraction on top of different storage. To make a quick comparison with a lock: * A lock allows only 1 process to access a resource; * A semaphore allow N process to access a resource. Basically, a lock is a semaphore where `N = 1`. PHP exposes some `sem_*` functions like [`sem_acquire`](http://php.net/sem_acquire). This module provides wrappers for the System V IPC family of functions. It includes semaphores, shared memory and inter-process messaging (IPC). The Lock component has a storage that works with theses functions. It uses it with `N = 1`. Wikipedia has some [examples](https://en.wikipedia.org/wiki/Semaphore_(programming)#Examples) But I can add one more commun use case. If you are building an async system that process user data, you may want to priorise all jobs. You can achieve that by running at maximum N jobs per user at the same time. If the user has more resources, you give him more concurrent jobs (so a bigger `N`). Thanks to semaphores, it's pretty easy to know if a new job can be run. I'm not saying the following services are using semaphore, but they may solve the previous problematic with semaphores. Here is some examples: * services like testing platform where a user can test N projects concurrently (travis, circle, appveyor, insight, ...) * services that ingest lots of data (newrelic, datadog, blackfire, segment.io, ...)) * services that send email in batch (campaign monitor, mailchimp, ...) * etc... To do so, since PHP is mono-threaded, you run M PHP workers. And in each worker, you look for for the next job. When you grab a job, you try to acquires a semaphore. If you got it, you process the job. If not you try another job. FTR in other language, like Go, there are no need to run M workers, one is enough. ```php connect('172.17.0.2'); // Internally, Semaphore needs a lock $lock = (new LockFactory(new LockRedisStore($redis)))->createLock('test:lock', 1); // Create a semaphore: // * name = test // * limit = 3 (it means only 3 process are allowed) // * ttl = 10 seconds : Maximum expected semaphore duration in seconds $semaphore = (new SemaphoreFactory($lock, new RedisStore($redis)))->createSemaphore('test', 3, 10); if (!$semaphore->acquire()) { echo "Could not acquire the semaphore\n"; exit(1); } // The semaphore has been acquired // Do the heavy job for ($i = 0; $i < 100; ++$i) { sleep(1); // Before the expiration, refresh the semaphore if the job is not finished yet if ($i % 9 === 0) { $semaphore->refresh(); } } // Release it when finished $semaphore->release(); ``` I looked at [packagist](https://packagist.org/?query=semaphore) and: * most of packages are using a semaphore storage for creating a lock. So there are not relevant here; * some packages need an async framework to be used (amphp for example); * the only packages really implementing a semaphore, has a really low code quality and some bugs. 1. I initially copied the Lock component since the external API is quite similar; 1. I simplified it a lot for the current use case; 1. I implemented the RedisStorage according the [redis book](https://redislabs.com/ebook/part-2-core-concepts/chapter-6-application-components-in-redis/6-3-counting-semaphores/;) 1. I forced a TTL on the storage. --- composer.json | 3 +- .../Component/Semaphore/.gitattributes | 3 + src/Symfony/Component/Semaphore/.gitignore | 3 + src/Symfony/Component/Semaphore/CHANGELOG.md | 7 + .../Exception/ExceptionInterface.php | 23 ++ .../Exception/InvalidArgumentException.php | 21 ++ .../Semaphore/Exception/RuntimeException.php | 21 ++ .../Exception/SemaphoreAcquiringException.php | 30 +++ .../Exception/SemaphoreExpiredException.php | 30 +++ .../Exception/SemaphoreReleasingException.php | 30 +++ src/Symfony/Component/Semaphore/Key.php | 104 +++++++ src/Symfony/Component/Semaphore/LICENSE | 19 ++ .../Semaphore/PersistingStoreInterface.php | 51 ++++ src/Symfony/Component/Semaphore/README.md | 16 ++ src/Symfony/Component/Semaphore/Semaphore.php | 158 +++++++++++ .../Component/Semaphore/SemaphoreFactory.php | 50 ++++ .../Semaphore/SemaphoreInterface.php | 61 +++++ .../Component/Semaphore/Store/RedisStore.php | 140 ++++++++++ .../Store/Resources/redis_delete.lua | 7 + .../Resources/redis_put_off_expiration.lua | 18 ++ .../Semaphore/Store/Resources/redis_save.lua | 43 +++ .../Semaphore/Store/StoreFactory.php | 63 +++++ .../Semaphore/Tests/SemaphoreFactoryTest.php | 37 +++ .../Semaphore/Tests/SemaphoreTest.php | 255 ++++++++++++++++++ .../Tests/Store/AbstractRedisStoreTest.php | 36 +++ .../Tests/Store/AbstractStoreTest.php | 202 ++++++++++++++ .../Semaphore/Tests/Store/PredisStoreTest.php | 39 +++ .../Tests/Store/RedisArrayStoreTest.php | 40 +++ .../Tests/Store/RedisClusterStoreTest.php | 38 +++ .../Semaphore/Tests/Store/RedisStoreTest.php | 40 +++ .../Tests/Store/StoreFactoryTest.php | 48 ++++ src/Symfony/Component/Semaphore/composer.json | 41 +++ .../Component/Semaphore/phpunit.xml.dist | 31 +++ 33 files changed, 1707 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Semaphore/.gitattributes create mode 100644 src/Symfony/Component/Semaphore/.gitignore create mode 100644 src/Symfony/Component/Semaphore/CHANGELOG.md create mode 100644 src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php create mode 100644 src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php create mode 100644 src/Symfony/Component/Semaphore/Exception/RuntimeException.php create mode 100644 src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php create mode 100644 src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php create mode 100644 src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php create mode 100644 src/Symfony/Component/Semaphore/Key.php create mode 100644 src/Symfony/Component/Semaphore/LICENSE create mode 100644 src/Symfony/Component/Semaphore/PersistingStoreInterface.php create mode 100644 src/Symfony/Component/Semaphore/README.md create mode 100644 src/Symfony/Component/Semaphore/Semaphore.php create mode 100644 src/Symfony/Component/Semaphore/SemaphoreFactory.php create mode 100644 src/Symfony/Component/Semaphore/SemaphoreInterface.php create mode 100644 src/Symfony/Component/Semaphore/Store/RedisStore.php create mode 100644 src/Symfony/Component/Semaphore/Store/Resources/redis_delete.lua create mode 100644 src/Symfony/Component/Semaphore/Store/Resources/redis_put_off_expiration.lua create mode 100644 src/Symfony/Component/Semaphore/Store/Resources/redis_save.lua create mode 100644 src/Symfony/Component/Semaphore/Store/StoreFactory.php create mode 100644 src/Symfony/Component/Semaphore/Tests/SemaphoreFactoryTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/SemaphoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/AbstractRedisStoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/PredisStoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/RedisArrayStoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/RedisClusterStoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/RedisStoreTest.php create mode 100644 src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php create mode 100644 src/Symfony/Component/Semaphore/composer.json create mode 100644 src/Symfony/Component/Semaphore/phpunit.xml.dist diff --git a/composer.json b/composer.json index 12a18228b5fa..85957b14be95 100644 --- a/composer.json +++ b/composer.json @@ -79,11 +79,12 @@ "symfony/property-info": "self.version", "symfony/proxy-manager-bridge": "self.version", "symfony/routing": "self.version", + "symfony/security-bundle": "self.version", "symfony/security-core": "self.version", "symfony/security-csrf": "self.version", "symfony/security-guard": "self.version", "symfony/security-http": "self.version", - "symfony/security-bundle": "self.version", + "symfony/semaphore": "self.version", "symfony/sendgrid-mailer": "self.version", "symfony/serializer": "self.version", "symfony/stopwatch": "self.version", diff --git a/src/Symfony/Component/Semaphore/.gitattributes b/src/Symfony/Component/Semaphore/.gitattributes new file mode 100644 index 000000000000..15f635e92c08 --- /dev/null +++ b/src/Symfony/Component/Semaphore/.gitattributes @@ -0,0 +1,3 @@ +/.gitignore export-ignore +/phpunit.xml.dist export-ignore +/Tests export-ignore diff --git a/src/Symfony/Component/Semaphore/.gitignore b/src/Symfony/Component/Semaphore/.gitignore new file mode 100644 index 000000000000..5414c2c655e7 --- /dev/null +++ b/src/Symfony/Component/Semaphore/.gitignore @@ -0,0 +1,3 @@ +composer.lock +phpunit.xml +vendor/ diff --git a/src/Symfony/Component/Semaphore/CHANGELOG.md b/src/Symfony/Component/Semaphore/CHANGELOG.md new file mode 100644 index 000000000000..37cc73919790 --- /dev/null +++ b/src/Symfony/Component/Semaphore/CHANGELOG.md @@ -0,0 +1,7 @@ +CHANGELOG +========= + +5.2.0 +----- + + * Introduced the component as experimental diff --git a/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php b/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php new file mode 100644 index 000000000000..616e0208cb8d --- /dev/null +++ b/src/Symfony/Component/Semaphore/Exception/ExceptionInterface.php @@ -0,0 +1,23 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Exception; + +/** + * Base ExceptionInterface for the Semaphore Component. + * + * @experimental in 5.2 + * + * @author Jérémy Derussé + */ +interface ExceptionInterface extends \Throwable +{ +} diff --git a/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php b/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.php new file mode 100644 index 000000000000..1eca46a4e3fd --- /dev/null +++ b/src/Symfony/Component/Semaphore/Exception/InvalidArgumentException.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\Semaphore\Exception; + +/** + * @experimental in 5.2 + * + * @author Jérémy Derussé + */ +class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Semaphore/Exception/RuntimeException.php b/src/Symfony/Component/Semaphore/Exception/RuntimeException.php new file mode 100644 index 000000000000..5119ae68db18 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Exception/RuntimeException.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\Semaphore\Exception; + +/** + * @experimental in 5.2 + * + * @author Grégoire Pineau + */ +class RuntimeException extends \RuntimeException implements ExceptionInterface +{ +} diff --git a/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php b/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php new file mode 100644 index 000000000000..d54c1af1ceea --- /dev/null +++ b/src/Symfony/Component/Semaphore/Exception/SemaphoreAcquiringException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Exception; + +use Symfony\Component\Semaphore\Key; + +/** + * SemaphoreAcquiringException is thrown when an issue happens during the acquisition of a semaphore. + * + * @experimental in 5.2 + * + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +class SemaphoreAcquiringException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(Key $key, string $message) + { + parent::__construct(sprintf('The semaphore "%s" could not be acquired: %s.', $key, $message)); + } +} diff --git a/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php b/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php new file mode 100644 index 000000000000..695df079bf90 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Exception/SemaphoreExpiredException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Exception; + +use Symfony\Component\Semaphore\Key; + +/** + * SemaphoreExpiredException is thrown when a semaphore may conflict due to a TTL expiration. + * + * @experimental in 5.2 + * + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +class SemaphoreExpiredException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(Key $key, string $message) + { + parent::__construct(sprintf('The semaphore "%s" has expired: %s.', $key, $message)); + } +} diff --git a/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php b/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php new file mode 100644 index 000000000000..a9979815d5f7 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Exception/SemaphoreReleasingException.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Exception; + +use Symfony\Component\Semaphore\Key; + +/** + * SemaphoreReleasingException is thrown when an issue happens during the release of a semaphore. + * + * @experimental in 5.2 + * + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +class SemaphoreReleasingException extends \RuntimeException implements ExceptionInterface +{ + public function __construct(Key $key, string $message) + { + parent::__construct(sprintf('The semaphore "%s" could not be released: %s.', $key, $message)); + } +} diff --git a/src/Symfony/Component/Semaphore/Key.php b/src/Symfony/Component/Semaphore/Key.php new file mode 100644 index 000000000000..741b795f18c0 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Key.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore; + +use Symfony\Component\Semaphore\Exception\InvalidArgumentException; + +/** + * Key is a container for the state of the semaphores in stores. + * + * @experimental in 5.2 + * + * @author Grégoire Pineau + * @author Jérémy Derussé + */ +final class Key +{ + private $resource; + private $limit; + private $weight; + private $expiringTime; + private $state = []; + + public function __construct(string $resource, int $limit, int $weight = 1) + { + if (1 > $limit) { + throw new InvalidArgumentException("The limit ($limit) should be greater than 0."); + } + if (1 > $weight) { + throw new InvalidArgumentException("The weight ($weight) should be greater than 0."); + } + if ($weight > $limit) { + throw new InvalidArgumentException("The weight ($weight) should be lower or equals to the limit ($limit)."); + } + $this->resource = $resource; + $this->limit = $limit; + $this->weight = $weight; + } + + public function __toString(): string + { + return $this->resource; + } + + public function getLimit(): int + { + return $this->limit; + } + + public function getWeight(): int + { + return $this->weight; + } + + public function hasState(string $stateKey): bool + { + return isset($this->state[$stateKey]); + } + + public function setState(string $stateKey, $state): void + { + $this->state[$stateKey] = $state; + } + + public function removeState(string $stateKey): void + { + unset($this->state[$stateKey]); + } + + public function getState(string $stateKey) + { + return $this->state[$stateKey]; + } + + public function reduceLifetime(float $ttlInSeconds) + { + $newTime = microtime(true) + $ttlInSeconds; + + if (null === $this->expiringTime || $this->expiringTime > $newTime) { + $this->expiringTime = $newTime; + } + } + + /** + * @return float|null Remaining lifetime in seconds. Null when the key won't expire. + */ + public function getRemainingLifetime(): ?float + { + return null === $this->expiringTime ? null : $this->expiringTime - microtime(true); + } + + public function isExpired(): bool + { + return null !== $this->expiringTime && $this->expiringTime <= microtime(true); + } +} diff --git a/src/Symfony/Component/Semaphore/LICENSE b/src/Symfony/Component/Semaphore/LICENSE new file mode 100644 index 000000000000..a7ec70801827 --- /dev/null +++ b/src/Symfony/Component/Semaphore/LICENSE @@ -0,0 +1,19 @@ +Copyright (c) 2016-2020 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/Semaphore/PersistingStoreInterface.php b/src/Symfony/Component/Semaphore/PersistingStoreInterface.php new file mode 100644 index 000000000000..df322d1355db --- /dev/null +++ b/src/Symfony/Component/Semaphore/PersistingStoreInterface.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\Semaphore; + +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; +use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; + +/** + * @experimental in 5.2 + * + * @author Grégoire Pineau + * @author Jérémy Derussé + */ +interface PersistingStoreInterface +{ + /** + * Stores the resource if the semaphore is not full. + * + * @throws SemaphoreAcquiringException + */ + public function save(Key $key, float $ttlInSecond); + + /** + * Removes a resource from the storage. + * + * @throws SemaphoreReleasingException + */ + public function delete(Key $key); + + /** + * Returns whether or not the resource exists in the storage. + */ + public function exists(Key $key): bool; + + /** + * Extends the TTL of a resource. + * + * @throws SemaphoreExpiredException + */ + public function putOffExpiration(Key $key, float $ttlInSecond); +} diff --git a/src/Symfony/Component/Semaphore/README.md b/src/Symfony/Component/Semaphore/README.md new file mode 100644 index 000000000000..75e707f7f04e --- /dev/null +++ b/src/Symfony/Component/Semaphore/README.md @@ -0,0 +1,16 @@ +Semaphore Component +=================== + +**This Component is experimental**. +[Experimental features](https://symfony.com/doc/current/contributing/code/experimental.html) +are not covered by Symfony's +[Backward Compatibility Promise](https://symfony.com/doc/current/contributing/code/bc.html). + +Resources +--------- + + * [Documentation](https://symfony.com/doc/master/components/semaphore.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/Semaphore/Semaphore.php b/src/Symfony/Component/Semaphore/Semaphore.php new file mode 100644 index 000000000000..931d675a929c --- /dev/null +++ b/src/Symfony/Component/Semaphore/Semaphore.php @@ -0,0 +1,158 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; +use Symfony\Component\Semaphore\Exception\InvalidArgumentException; +use Symfony\Component\Semaphore\Exception\RuntimeException; +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; +use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; + +/** + * Semaphore is the default implementation of the SemaphoreInterface. + * + * @experimental in 5.2 + * + * @author Grégoire Pineau + * @author Jérémy Derussé + */ +final class Semaphore implements SemaphoreInterface, LoggerAwareInterface +{ + use LoggerAwareTrait; + + private $store; + private $key; + private $ttlInSecond; + private $autoRelease; + private $dirty = false; + + public function __construct(Key $key, PersistingStoreInterface $store, float $ttlInSecond = 300.0, bool $autoRelease = true) + { + $this->store = $store; + $this->key = $key; + $this->ttlInSecond = $ttlInSecond; + $this->autoRelease = $autoRelease; + + $this->logger = new NullLogger(); + } + + /** + * Automatically releases the underlying semaphore when the object is destructed. + */ + public function __destruct() + { + if (!$this->autoRelease || !$this->dirty || !$this->isAcquired()) { + return; + } + + $this->release(); + } + + /** + * {@inheritdoc} + */ + public function acquire(): bool + { + try { + $this->store->save($this->key, $this->ttlInSecond); + $this->key->reduceLifetime($this->ttlInSecond); + $this->dirty = true; + + $this->logger->debug('Successfully acquired the "{resource}" semaphore.', ['resource' => $this->key]); + + return true; + } catch (SemaphoreAcquiringException $e) { + $this->logger->notice('Failed to acquire the "{resource}" semaphore. Someone else already acquired the semaphore.', ['resource' => $this->key]); + + return false; + } catch (\Exception $e) { + $this->logger->notice('Failed to acquire the "{resource}" semaphore.', ['resource' => $this->key, 'exception' => $e]); + + throw new RuntimeException(sprintf('Failed to acquire the "%s" semaphore.', $this->key), 0, $e); + } + } + + /** + * {@inheritdoc} + */ + public function refresh(?float $ttlInSecond = null) + { + if (null === $ttlInSecond) { + $ttlInSecond = $this->ttlInSecond; + } + if (!$ttlInSecond) { + throw new InvalidArgumentException('You have to define an expiration duration.'); + } + + try { + $this->store->putOffExpiration($this->key, $ttlInSecond); + $this->key->reduceLifetime($ttlInSecond); + + $this->dirty = true; + + $this->logger->debug('Expiration defined for "{resource}" semaphore for "{ttlInSecond}" seconds.', ['resource' => $this->key, 'ttlInSecond' => $ttlInSecond]); + } catch (SemaphoreExpiredException $e) { + $this->dirty = false; + $this->logger->notice('Failed to define an expiration for the "{resource}" semaphore, the semaphore has expired.', ['resource' => $this->key]); + + throw $e; + } catch (\Exception $e) { + $this->logger->notice('Failed to define an expiration for the "{resource}" semaphore.', ['resource' => $this->key, 'exception' => $e]); + + throw new RuntimeException(sprintf('Failed to define an expiration for the "%s" semaphore.', $this->key), 0, $e); + } + } + + /** + * {@inheritdoc} + */ + public function isAcquired(): bool + { + return $this->dirty = $this->store->exists($this->key); + } + + /** + * {@inheritdoc} + */ + public function release() + { + try { + $this->store->delete($this->key); + $this->dirty = false; + } catch (SemaphoreReleasingException $e) { + throw $e; + } catch (\Exception $e) { + $this->logger->notice('Failed to release the "{resource}" semaphore.', ['resource' => $this->key]); + + throw new RuntimeException(sprintf('Failed to release the "%s" semaphore.', $this->key), 0, $e); + } + } + + /** + * {@inheritdoc} + */ + public function isExpired(): bool + { + return $this->key->isExpired(); + } + + /** + * {@inheritdoc} + */ + public function getRemainingLifetime(): ?float + { + return $this->key->getRemainingLifetime(); + } +} diff --git a/src/Symfony/Component/Semaphore/SemaphoreFactory.php b/src/Symfony/Component/Semaphore/SemaphoreFactory.php new file mode 100644 index 000000000000..bf4743e8f767 --- /dev/null +++ b/src/Symfony/Component/Semaphore/SemaphoreFactory.php @@ -0,0 +1,50 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore; + +use Psr\Log\LoggerAwareInterface; +use Psr\Log\LoggerAwareTrait; +use Psr\Log\NullLogger; + +/** + * Factory provides method to create semaphores. + * + * @experimental in 5.2 + * + * @author Grégoire Pineau + * @author Jérémy Derussé + * @author Hamza Amrouche + */ +class SemaphoreFactory implements LoggerAwareInterface +{ + use LoggerAwareTrait; + + private $store; + + public function __construct(PersistingStoreInterface $store) + { + $this->store = $store; + $this->logger = new NullLogger(); + } + + /** + * @param float|null $ttlInSecond Maximum expected semaphore duration in seconds + * @param bool $autoRelease Whether to automatically release the semaphore or not when the semaphore instance is destroyed + */ + public function createSemaphore(string $resource, int $limit, int $weight = 1, ?float $ttlInSecond = 300.0, bool $autoRelease = true): SemaphoreInterface + { + $semaphore = new Semaphore(new Key($resource, $limit, $weight), $this->store, $ttlInSecond, $autoRelease); + $semaphore->setLogger($this->logger); + + return $semaphore; + } +} diff --git a/src/Symfony/Component/Semaphore/SemaphoreInterface.php b/src/Symfony/Component/Semaphore/SemaphoreInterface.php new file mode 100644 index 000000000000..cbc1f36db4ce --- /dev/null +++ b/src/Symfony/Component/Semaphore/SemaphoreInterface.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore; + +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; + +/** + * SemaphoreInterface defines an interface to manipulate the status of a semaphore. + * + * @experimental in 5.2 + * + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +interface SemaphoreInterface +{ + /** + * Acquires the semaphore. If the semaphore has reached its limit. + * + * @return bool whether or not the semaphore had been acquired + * + * @throws SemaphoreAcquiringException If the semaphore can not be acquired + */ + public function acquire(): bool; + + /** + * Increase the duration of an acquired semaphore. + * + * @throws SemaphoreExpiredException If the semaphore has expired + */ + public function refresh(float $ttlInSecond = null); + + /** + * Returns whether or not the semaphore is acquired. + */ + public function isAcquired(): bool; + + /** + * Release the semaphore. + * + * @throws SemaphoreReleasingException If the semaphore can not be released + */ + public function release(); + + public function isExpired(): bool; + + /** + * Returns the remaining lifetime. + */ + public function getRemainingLifetime(): ?float; +} diff --git a/src/Symfony/Component/Semaphore/Store/RedisStore.php b/src/Symfony/Component/Semaphore/Store/RedisStore.php new file mode 100644 index 000000000000..a1f41a9d6421 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Store/RedisStore.php @@ -0,0 +1,140 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Store; + +use Symfony\Component\Cache\Traits\RedisClusterProxy; +use Symfony\Component\Cache\Traits\RedisProxy; +use Symfony\Component\Semaphore\Exception\InvalidArgumentException; +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; +use Symfony\Component\Semaphore\Key; +use Symfony\Component\Semaphore\PersistingStoreInterface; + +/** + * RedisStore is a PersistingStoreInterface implementation using Redis as store engine. + * + * @experimental in 5.2 + * + * @author Grégoire Pineau + * @author Jérémy Derussé + */ +class RedisStore implements PersistingStoreInterface +{ + private $redis; + + /** + * @param \Redis|\RedisArray|\RedisCluster|\RedisClusterProxy|\Predis\ClientInterface $redisClient + */ + public function __construct($redisClient) + { + if (!$redisClient instanceof \Redis && !$redisClient instanceof \RedisArray && !$redisClient instanceof \RedisCluster && !$redisClient instanceof \Predis\ClientInterface && !$redisClient instanceof RedisProxy && !$redisClient instanceof RedisClusterProxy) { + throw new InvalidArgumentException(sprintf('"%s()" expects parameter 1 to be Redis, RedisArray, RedisCluster, RedisProxy, RedisClusterProxy or Predis\ClientInterface, "%s" given.', __METHOD__, get_debug_type($redisClient))); + } + + $this->redis = $redisClient; + } + + /** + * {@inheritdoc} + */ + public function save(Key $key, float $ttlInSecond) + { + if (0 > $ttlInSecond) { + throw new InvalidArgumentException("The TTL should be greater than 0, '$ttlInSecond' given."); + } + + $script = file_get_contents(__DIR__.'/Resources/redis_save.lua'); + + $args = [ + $this->getUniqueToken($key), + time(), + $ttlInSecond, + $key->getLimit(), + $key->getWeight(), + ]; + + if (!$this->evaluate($script, sprintf('{%s}', $key), $args)) { + throw new SemaphoreAcquiringException($key, 'the script return false'); + } + } + + /** + * {@inheritdoc} + */ + public function putOffExpiration(Key $key, float $ttlInSecond) + { + if (0 > $ttlInSecond) { + throw new InvalidArgumentException("The TTL should be greater than 0, '$ttlInSecond' given."); + } + + $script = file_get_contents(__DIR__.'/Resources/redis_put_off_expiration.lua'); + + if ($this->evaluate($script, sprintf('{%s}', $key), [time() + $ttlInSecond, $this->getUniqueToken($key)])) { + throw new SemaphoreExpiredException($key, 'the script returns false'); + } + } + + /** + * {@inheritdoc} + */ + public function delete(Key $key) + { + $script = file_get_contents(__DIR__.'/Resources/redis_delete.lua'); + + $this->evaluate($script, sprintf('{%s}', $key), [$this->getUniqueToken($key)]); + } + + /** + * {@inheritdoc} + */ + public function exists(Key $key): bool + { + return (bool) $this->redis->zScore(sprintf('{%s}:weight', $key), $this->getUniqueToken($key)); + } + + /** + * Evaluates a script in the corresponding redis client. + * + * @return mixed + */ + private function evaluate(string $script, string $resource, array $args) + { + if ( + $this->redis instanceof \Redis || + $this->redis instanceof \RedisCluster || + $this->redis instanceof RedisProxy || + $this->redis instanceof RedisClusterProxy + ) { + return $this->redis->eval($script, array_merge([$resource], $args), 1); + } + + if ($this->redis instanceof \RedisArray) { + return $this->redis->_instance($this->redis->_target($resource))->eval($script, array_merge([$resource], $args), 1); + } + + if ($this->redis instanceof \Predis\ClientInterface) { + return $this->redis->eval(...array_merge([$script, 1, $resource], $args)); + } + + throw new InvalidArgumentException(sprintf('"%s()" expects being initialized with a Redis, RedisArray, RedisCluster or Predis\ClientInterface, "%s" given.', __METHOD__, \is_object($this->redis) ? \get_class($this->redis) : \gettype($this->redis))); + } + + private function getUniqueToken(Key $key): string + { + if (!$key->hasState(__CLASS__)) { + $token = base64_encode(random_bytes(32)); + $key->setState(__CLASS__, $token); + } + + return $key->getState(__CLASS__); + } +} diff --git a/src/Symfony/Component/Semaphore/Store/Resources/redis_delete.lua b/src/Symfony/Component/Semaphore/Store/Resources/redis_delete.lua new file mode 100644 index 000000000000..1405a7a6bbac --- /dev/null +++ b/src/Symfony/Component/Semaphore/Store/Resources/redis_delete.lua @@ -0,0 +1,7 @@ +local key = KEYS[1] +local weightKey = key .. ":weight" +local timeKey = key .. ":time" +local identifier = ARGV[1] + +redis.call("ZREM", timeKey, identifier) +return redis.call("ZREM", weightKey, identifier) diff --git a/src/Symfony/Component/Semaphore/Store/Resources/redis_put_off_expiration.lua b/src/Symfony/Component/Semaphore/Store/Resources/redis_put_off_expiration.lua new file mode 100644 index 000000000000..0c4ff3fb8aa7 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Store/Resources/redis_put_off_expiration.lua @@ -0,0 +1,18 @@ +local key = KEYS[1] +local weightKey = key .. ":weight" +local timeKey = key .. ":time" + +local added = redis.call("ZADD", timeKey, ARGV[1], ARGV[2]) +if added == 1 then + redis.call("ZREM", timeKey, ARGV[2]) + redis.call("ZREM", weightKey, ARGV[2]) +end + +-- Extend the TTL +local curentTtl = redis.call("TTL", weightKey) +if curentTtl < now + ttlInSecond then + redis.call("EXPIRE", weightKey, curentTtl + 10) + redis.call("EXPIRE", timeKey, curentTtl + 10) +end + +return added diff --git a/src/Symfony/Component/Semaphore/Store/Resources/redis_save.lua b/src/Symfony/Component/Semaphore/Store/Resources/redis_save.lua new file mode 100644 index 000000000000..50942b53c988 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Store/Resources/redis_save.lua @@ -0,0 +1,43 @@ +local key = KEYS[1] +local weightKey = key .. ":weight" +local timeKey = key .. ":time" +local identifier = ARGV[1] +local now = tonumber(ARGV[2]) +local ttlInSecond = tonumber(ARGV[3]) +local limit = tonumber(ARGV[4]) +local weight = tonumber(ARGV[5]) + +-- Remove expired values +redis.call("ZREMRANGEBYSCORE", timeKey, "-inf", now) +redis.call("ZINTERSTORE", weightKey, 2, weightKey, timeKey, "WEIGHTS", 1, 0) + +-- Semaphore already acquired? +if redis.call("ZSCORE", timeKey, identifier) then + return true +end + +-- Try to get a semaphore +local semaphores = redis.call("ZRANGE", weightKey, 0, -1, "WITHSCORES") +local count = 0 + +for i = 1, #semaphores, 2 do + count = count + semaphores[i+1] +end + +-- Could we get the semaphore ? +if count + weight > limit then + return false +end + +-- Acquire the semaphore +redis.call("ZADD", timeKey, now + ttlInSecond, identifier) +redis.call("ZADD", weightKey, weight, identifier) + +-- Extend the TTL +local curentTtl = redis.call("TTL", weightKey) +if curentTtl < now + ttlInSecond then + redis.call("EXPIRE", weightKey, curentTtl + 10) + redis.call("EXPIRE", timeKey, curentTtl + 10) +end + +return true diff --git a/src/Symfony/Component/Semaphore/Store/StoreFactory.php b/src/Symfony/Component/Semaphore/Store/StoreFactory.php new file mode 100644 index 000000000000..c42eda627e13 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Store/StoreFactory.php @@ -0,0 +1,63 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Store; + +use Doctrine\DBAL\Connection; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Traits\RedisClusterProxy; +use Symfony\Component\Cache\Traits\RedisProxy; +use Symfony\Component\Semaphore\Exception\InvalidArgumentException; +use Symfony\Component\Semaphore\PersistingStoreInterface; + +/** + * StoreFactory create stores and connections. + * + * @experimental in 5.2 + * + * @author Jérémy Derussé + * @author Jérémy Derussé + */ +class StoreFactory +{ + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface|RedisProxy|RedisClusterProxy|string $connection Connection or DSN or Store short name + */ + public static function createStore($connection): PersistingStoreInterface + { + if (!\is_string($connection) && !\is_object($connection)) { + throw new \TypeError(sprintf('Argument 1 passed to "%s()" must be a string or a connection object, "%s" given.', __METHOD__, \gettype($connection))); + } + + switch (true) { + case $connection instanceof \Redis: + case $connection instanceof \RedisArray: + case $connection instanceof \RedisCluster: + case $connection instanceof \Predis\ClientInterface: + case $connection instanceof RedisProxy: + case $connection instanceof RedisClusterProxy: + return new RedisStore($connection); + + case !\is_string($connection): + throw new InvalidArgumentException(sprintf('Unsupported Connection: "%s".', \get_class($connection))); + case 0 === strpos($connection, 'redis://'): + case 0 === strpos($connection, 'rediss://'): + if (!class_exists(AbstractAdapter::class)) { + throw new InvalidArgumentException(sprintf('Unsupported DSN "%s". Try running "composer require symfony/cache".', $connection)); + } + $connection = AbstractAdapter::createConnection($connection, ['lazy' => true]); + + return new RedisStore($connection); + } + + throw new InvalidArgumentException(sprintf('Unsupported Connection: "%s".', $connection)); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/SemaphoreFactoryTest.php b/src/Symfony/Component/Semaphore/Tests/SemaphoreFactoryTest.php new file mode 100644 index 000000000000..cf7915463269 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/SemaphoreFactoryTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\Semaphore\PersistingStoreInterface; +use Symfony\Component\Semaphore\SemaphoreFactory; +use Symfony\Component\Semaphore\SemaphoreInterface; + +/** + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +class SemaphoreFactoryTest extends TestCase +{ + public function testCreateSemaphore() + { + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $logger = $this->getMockBuilder(LoggerInterface::class)->getMock(); + $factory = new SemaphoreFactory($store); + $factory->setLogger($logger); + + $semaphore = $factory->createSemaphore('foo', 4); + + $this->assertInstanceOf(SemaphoreInterface::class, $semaphore); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/SemaphoreTest.php b/src/Symfony/Component/Semaphore/Tests/SemaphoreTest.php new file mode 100644 index 000000000000..9efe71b9c3e6 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/SemaphoreTest.php @@ -0,0 +1,255 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests; + +use PHPUnit\Framework\TestCase; +use RuntimeException; +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Exception\SemaphoreExpiredException; +use Symfony\Component\Semaphore\Exception\SemaphoreReleasingException; +use Symfony\Component\Semaphore\Key; +use Symfony\Component\Semaphore\PersistingStoreInterface; +use Symfony\Component\Semaphore\Semaphore; + +/** + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +class SemaphoreTest extends TestCase +{ + public function testAcquireReturnsTrue() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('save') + ->with($key, 300.0) + ; + + $this->assertTrue($semaphore->acquire()); + $this->assertGreaterThanOrEqual(299.0, $key->getRemainingLifetime()); + } + + public function testAcquireReturnsFalse() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('save') + ->with($key, 300.0) + ->willThrowException(new SemaphoreAcquiringException($key, 'message')) + ; + + $this->assertFalse($semaphore->acquire()); + $this->assertNull($key->getRemainingLifetime()); + } + + public function testAcquireThrowException() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('save') + ->with($key, 300.0) + ->willThrowException(new \RuntimeException()) + ; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to acquire the "key" semaphore.'); + + $semaphore->acquire(); + } + + public function testRefresh() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store, 10.0); + + $store + ->expects($this->once()) + ->method('putOffExpiration') + ->with($key, 10.0) + ; + + $semaphore->refresh(); + $this->assertGreaterThanOrEqual(9.0, $key->getRemainingLifetime()); + } + + public function testRefreshWithCustomTtl() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store, 10.0); + + $store + ->expects($this->once()) + ->method('putOffExpiration') + ->with($key, 40.0) + ; + + $semaphore->refresh(40.0); + $this->assertGreaterThanOrEqual(39.0, $key->getRemainingLifetime()); + } + + public function testRefreshWhenItFails() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('putOffExpiration') + ->with($key, 300.0) + ->willThrowException(new SemaphoreExpiredException($key, 'message')) + ; + + $this->expectException(SemaphoreExpiredException::class); + $this->expectExceptionMessage('The semaphore "key" has expired: message.'); + + $semaphore->refresh(); + } + + public function testRefreshWhenItFailsHard() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('putOffExpiration') + ->with($key, 300.0) + ->willThrowException(new \RuntimeException()) + ; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to define an expiration for the "key" semaphore.'); + + $semaphore->refresh(); + } + + public function testRelease() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('delete') + ->with($key) + ; + + $semaphore->release(); + } + + public function testReleaseWhenItFails() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('delete') + ->with($key) + ->willThrowException(new SemaphoreReleasingException($key, 'message')) + ; + + $this->expectException(SemaphoreReleasingException::class); + $this->expectExceptionMessage('The semaphore "key" could not be released: message.'); + + $semaphore->release(); + } + + public function testReleaseWhenItFailsHard() + { + $key = new Key('key', 1); + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + $semaphore = new Semaphore($key, $store); + + $store + ->expects($this->once()) + ->method('delete') + ->with($key) + ->willThrowException(new \RuntimeException()) + ; + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Failed to release the "key" semaphore.'); + + $semaphore->release(); + } + + public function testReleaseOnDestruction() + { + $key = new Key('key', 1); + $store = $this->createMock(PersistingStoreInterface::class); + $semaphore = new Semaphore($key, $store); + + $store + ->method('exists') + ->willReturn(true) + ; + $store + ->expects($this->once()) + ->method('delete') + ; + + $semaphore->acquire(); + unset($semaphore); + } + + public function testNoAutoReleaseWhenNotConfigured() + { + $key = new Key('key', 1); + $store = $this->createMock(PersistingStoreInterface::class); + $semaphore = new Semaphore($key, $store, 10.0, false); + + $store + ->method('exists') + ->willReturn(true) + ; + $store + ->expects($this->never()) + ->method('delete') + ; + + $semaphore->acquire(); + unset($semaphore); + } + + public function testExpiration() + { + $store = $this->getMockBuilder(PersistingStoreInterface::class)->getMock(); + + $key = new Key('key', 1); + $semaphore = new Semaphore($key, $store); + $this->assertFalse($semaphore->isExpired()); + + $key = new Key('key', 1); + $key->reduceLifetime(0.0); + $semaphore = new Semaphore($key, $store); + $this->assertTrue($semaphore->isExpired()); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/AbstractRedisStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/AbstractRedisStoreTest.php new file mode 100644 index 000000000000..11bb93168452 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/AbstractRedisStoreTest.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\Semaphore\Tests\Store; + +use Symfony\Component\Semaphore\PersistingStoreInterface; +use Symfony\Component\Semaphore\Store\RedisStore; + +/** + * @author Jérémy Derussé + */ +abstract class AbstractRedisStoreTest extends AbstractStoreTest +{ + /** + * Return a RedisConnection. + * + * @return \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface + */ + abstract protected function getRedisConnection(): object; + + /** + * {@inheritdoc} + */ + public function getStore(): PersistingStoreInterface + { + return new RedisStore($this->getRedisConnection()); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTest.php new file mode 100644 index 000000000000..8715d4d11c5b --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/AbstractStoreTest.php @@ -0,0 +1,202 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests\Store; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Semaphore\Exception\SemaphoreAcquiringException; +use Symfony\Component\Semaphore\Key; +use Symfony\Component\Semaphore\PersistingStoreInterface; + +/** + * @author Jérémy Derussé + * @author Grégoire Pineau + */ +abstract class AbstractStoreTest extends TestCase +{ + abstract protected function getStore(): PersistingStoreInterface; + + public function testSaveExistAndDelete() + { + $store = $this->getStore(); + + $key = new Key('key', 1); + + $this->assertFalse($store->exists($key)); + $store->save($key, 10); + $this->assertTrue($store->exists($key)); + $store->delete($key); + $this->assertFalse($store->exists($key)); + } + + public function testSaveWithDifferentResources() + { + $store = $this->getStore(); + + $key1 = new Key('key1', 1); + $key2 = new Key('key2', 1); + + $store->save($key1, 10); + $this->assertTrue($store->exists($key1)); + $this->assertFalse($store->exists($key2)); + + $store->save($key2, 10); + $this->assertTrue($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + + $store->delete($key1); + $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 testSaveWithDifferentKeysOnSameResource() + { + $store = $this->getStore(); + + $resource = 'resource'; + $key1 = new Key($resource, 1); + $key2 = new Key($resource, 1); + + $store->save($key1, 10); + $this->assertTrue($store->exists($key1)); + $this->assertFalse($store->exists($key2)); + + try { + $store->save($key2, 10); + $this->fail('The store shouldn\'t save the second key'); + } catch (SemaphoreAcquiringException $e) { + } + + // The failure of previous attempt should not impact the state of current semaphores + $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, 10); + $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 testSaveWithLimitAt2() + { + $store = $this->getStore(); + + $resource = 'resource'; + $key1 = new Key($resource, 2); + $key2 = new Key($resource, 2); + $key3 = new Key($resource, 2); + + $store->save($key1, 10); + $this->assertTrue($store->exists($key1)); + $this->assertFalse($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + $store->save($key2, 10); + $this->assertTrue($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + try { + $store->save($key3, 10); + $this->fail('The store shouldn\'t save the third key'); + } catch (SemaphoreAcquiringException $e) { + } + + // The failure of previous attempt should not impact the state of current semaphores + $this->assertTrue($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + $store->delete($key1); + $this->assertFalse($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + $store->save($key3, 10); + $this->assertFalse($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertTrue($store->exists($key3)); + + $store->delete($key2); + $store->delete($key3); + } + + public function testSaveWithWeightAndLimitAt3() + { + $store = $this->getStore(); + + $resource = 'resource'; + $key1 = new Key($resource, 4, 2); + $key2 = new Key($resource, 4, 2); + $key3 = new Key($resource, 4, 2); + + $store->save($key1, 10); + $this->assertTrue($store->exists($key1)); + $this->assertFalse($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + $store->save($key2, 10); + $this->assertTrue($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + try { + $store->save($key3, 10); + $this->fail('The store shouldn\'t save the third key'); + } catch (SemaphoreAcquiringException $e) { + } + + // The failure of previous attempt should not impact the state of current semaphores + $this->assertTrue($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + $store->delete($key1); + $this->assertFalse($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertFalse($store->exists($key3)); + + $store->save($key3, 10); + $this->assertFalse($store->exists($key1)); + $this->assertTrue($store->exists($key2)); + $this->assertTrue($store->exists($key3)); + + $store->delete($key2); + $store->delete($key3); + } + + public function testSaveTwice() + { + $store = $this->getStore(); + + $resource = 'resource'; + $key = new Key($resource, 1); + + $store->save($key, 10); + $store->save($key, 10); + + // just asserts it don't throw an exception + $this->addToAssertionCount(1); + + $store->delete($key); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/PredisStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/PredisStoreTest.php new file mode 100644 index 000000000000..cc4d5a766ed5 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/PredisStoreTest.php @@ -0,0 +1,39 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests\Store; + +/** + * @author Jérémy Derussé + */ +class PredisStoreTest extends AbstractRedisStoreTest +{ + public static function setUpBeforeClass(): void + { + $redis = new \Predis\Client('tcp://'.getenv('REDIS_HOST').':6379'); + try { + $redis->connect(); + } catch (\Exception $e) { + self::markTestSkipped($e->getMessage()); + } + } + + /** + * @return \Predis\Client + */ + protected function getRedisConnection(): object + { + $redis = new \Predis\Client('tcp://'.getenv('REDIS_HOST').':6379'); + $redis->connect(); + + return $redis; + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/RedisArrayStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/RedisArrayStoreTest.php new file mode 100644 index 000000000000..cf1934d26e59 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/RedisArrayStoreTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests\Store; + +/** + * @author Jérémy Derussé + * + * @requires extension redis + */ +class RedisArrayStoreTest extends AbstractRedisStoreTest +{ + public static function setUpBeforeClass(): void + { + if (!class_exists('RedisArray')) { + self::markTestSkipped('The RedisArray class is required.'); + } + try { + (new \Redis())->connect(getenv('REDIS_HOST')); + } catch (\Exception $e) { + self::markTestSkipped($e->getMessage()); + } + } + + /** + * @return \RedisArray + */ + protected function getRedisConnection(): object + { + return new \RedisArray([getenv('REDIS_HOST')]); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/RedisClusterStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/RedisClusterStoreTest.php new file mode 100644 index 000000000000..0087300e5887 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/RedisClusterStoreTest.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\Semaphore\Tests\Store; + +/** + * @author Jérémy Derussé + * + * @requires extension redis + */ +class RedisClusterStoreTest extends AbstractRedisStoreTest +{ + public static function setUpBeforeClass(): void + { + if (!class_exists(\RedisCluster::class)) { + self::markTestSkipped('The RedisCluster class is required.'); + } + if (!getenv('REDIS_CLUSTER_HOSTS')) { + self::markTestSkipped('REDIS_CLUSTER_HOSTS env var is not defined.'); + } + } + + /** + * @return \RedisCluster + */ + protected function getRedisConnection(): object + { + return new \RedisCluster(null, explode(' ', getenv('REDIS_CLUSTER_HOSTS'))); + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/RedisStoreTest.php b/src/Symfony/Component/Semaphore/Tests/Store/RedisStoreTest.php new file mode 100644 index 000000000000..5b05f38355e1 --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/RedisStoreTest.php @@ -0,0 +1,40 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests\Store; + +/** + * @author Jérémy Derussé + * + * @requires extension redis + */ +class RedisStoreTest extends AbstractRedisStoreTest +{ + public static function setUpBeforeClass(): void + { + try { + (new \Redis())->connect(getenv('REDIS_HOST')); + } catch (\Exception $e) { + self::markTestSkipped($e->getMessage()); + } + } + + /** + * @return \Redis + */ + protected function getRedisConnection(): object + { + $redis = new \Redis(); + $redis->connect(getenv('REDIS_HOST')); + + return $redis; + } +} diff --git a/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php b/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php new file mode 100644 index 000000000000..8deec34b444b --- /dev/null +++ b/src/Symfony/Component/Semaphore/Tests/Store/StoreFactoryTest.php @@ -0,0 +1,48 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Semaphore\Tests\Store; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Cache\Adapter\AbstractAdapter; +use Symfony\Component\Cache\Traits\RedisProxy; +use Symfony\Component\Semaphore\Store\RedisStore; +use Symfony\Component\Semaphore\Store\StoreFactory; + +/** + * @author Jérémy Derussé + */ +class StoreFactoryTest extends TestCase +{ + /** + * @dataProvider validConnections + */ + public function testCreateStore($connection, string $expectedStoreClass) + { + $store = StoreFactory::createStore($connection); + + $this->assertInstanceOf($expectedStoreClass, $store); + } + + public function validConnections() + { + if (class_exists(\Redis::class)) { + yield [$this->createMock(\Redis::class), RedisStore::class]; + } + if (class_exists(RedisProxy::class)) { + yield [$this->createMock(RedisProxy::class), RedisStore::class]; + } + yield [new \Predis\Client(), RedisStore::class]; + if (class_exists(\Redis::class) && class_exists(AbstractAdapter::class)) { + yield ['redis://localhost', RedisStore::class]; + } + } +} diff --git a/src/Symfony/Component/Semaphore/composer.json b/src/Symfony/Component/Semaphore/composer.json new file mode 100644 index 000000000000..f99bbed760cf --- /dev/null +++ b/src/Symfony/Component/Semaphore/composer.json @@ -0,0 +1,41 @@ +{ + "name": "symfony/semaphore", + "type": "library", + "description": "Symfony Semaphore Component", + "keywords": ["semaphore"], + "homepage": "https://symfony.com", + "license": "MIT", + "authors": [ + { + "name": "Grégoire Pineau", + "email": "lyrixx@lyrixx.info" + }, + { + "name": "Jérémy Derussé", + "email": "jeremy@derusse.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "require": { + "php": "^7.2.5", + "psr/log": "~1.0" + }, + "require-dev": { + "predis/predis": "~1.0" + }, + "autoload": { + "psr-4": { "Symfony\\Component\\Semaphore\\": "" }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "minimum-stability": "dev", + "extra": { + "branch-alias": { + "dev-master": "5.1-dev" + } + } +} diff --git a/src/Symfony/Component/Semaphore/phpunit.xml.dist b/src/Symfony/Component/Semaphore/phpunit.xml.dist new file mode 100644 index 000000000000..90e46c671c8b --- /dev/null +++ b/src/Symfony/Component/Semaphore/phpunit.xml.dist @@ -0,0 +1,31 @@ + + + + + + + + + + + ./Tests/ + + + + + + ./ + + ./Tests + ./vendor + + + + pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy