diff --git a/composer.json b/composer.json index 12a18228b5fa4..85957b14be958 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 0000000000000..15f635e92c089 --- /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 0000000000000..5414c2c655e72 --- /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 0000000000000..37cc739197900 --- /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 0000000000000..616e0208cb8d1 --- /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 0000000000000..1eca46a4e3fd0 --- /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 0000000000000..5119ae68db185 --- /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 0000000000000..d54c1af1ceea1 --- /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 0000000000000..695df079bf90b --- /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 0000000000000..a9979815d5f7c --- /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 0000000000000..741b795f18c01 --- /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 0000000000000..a7ec70801827a --- /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 0000000000000..df322d1355dbe --- /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 0000000000000..75e707f7f04ed --- /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 0000000000000..931d675a929cb --- /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 0000000000000..bf4743e8f767b --- /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 0000000000000..cbc1f36db4cef --- /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 0000000000000..a1f41a9d64212 --- /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 0000000000000..1405a7a6bbac5 --- /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 0000000000000..0c4ff3fb8aa7e --- /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 0000000000000..50942b53c9883 --- /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 0000000000000..c42eda627e137 --- /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 0000000000000..cf79154632691 --- /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 0000000000000..9efe71b9c3e65 --- /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 0000000000000..11bb931684528 --- /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 0000000000000..8715d4d11c5bd --- /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 0000000000000..cc4d5a766ed56 --- /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 0000000000000..cf1934d26e59f --- /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 0000000000000..0087300e58875 --- /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 0000000000000..5b05f38355e19 --- /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 0000000000000..8deec34b444b9 --- /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 0000000000000..f99bbed760cfa --- /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 0000000000000..90e46c671c8bc --- /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