diff --git a/LICENSE b/LICENSE index 62167823..b146be6c 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2024 Willem Stuursma-Ruwen +Copyright (c) 2024 Markus Malkusch, Willem Stuursma-Ruwen, Michael Voříšek and GitHub contributors Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index cb27b51e..b3169bb3 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,9 @@ [![Build Status](https://github.com/php-lock/lock/actions/workflows/test-unit.yml/badge.svg?branch=master)](https://github.com/php-lock/lock/actions?query=branch:master) [![License](https://poser.pugx.org/malkusch/lock/license)](https://packagist.org/packages/malkusch/lock) -This library helps executing critical code in concurrent situations. +This library helps executing critical code in concurrent situations in serialized fashion. -php-lock/lock follows semantic versioning. Read more on [semver.org][1]. +php-lock/lock follows [semantic versioning][1]. ---- @@ -164,8 +164,7 @@ implementations or create/extend your own implementation. - [`FlockMutex`](#flockmutex) - [`MemcachedMutex`](#memcachedmutex) -- [`PHPRedisMutex`](#phpredismutex) -- [`PredisMutex`](#predismutex) +- [`RedisMutex`](#redismutex) - [`SemaphoreMutex`](#semaphoremutex) - [`TransactionalMutex`](#transactionalmutex) - [`MySQLMutex`](#mysqlmutex) @@ -195,7 +194,7 @@ extension if possible or busy waiting if not. #### MemcachedMutex The **MemcachedMutex** is a spinlock implementation which uses the -[`Memcached` API](http://php.net/manual/en/book.memcached.php). +[`Memcached` extension](http://php.net/manual/en/book.memcached.php). Example: ```php @@ -213,13 +212,14 @@ $mutex->synchronized(function () use ($bankAccount, $amount) { }); ``` -#### PHPRedisMutex +#### RedisMutex -The **PHPRedisMutex** is the distributed lock implementation of -[RedLock](http://redis.io/topics/distlock) which uses the -[`phpredis` extension](https://github.com/phpredis/phpredis). +The **RedisMutex** is the distributed lock implementation of +[RedLock](http://redis.io/topics/distlock) which supports the +[`phpredis` extension](https://github.com/phpredis/phpredis) +or [`Predis` API](https://github.com/nrk/predis). -This implementation requires at least `phpredis-2.2.4`. +Both Redis and Valkey servers are supported. If used with a cluster of Redis servers, acquiring and releasing locks will continue to function as long as a majority of the servers still works. @@ -228,29 +228,9 @@ Example: ```php $redis = new \Redis(); $redis->connect('localhost'); +// OR $redis = new \Predis\Client('redis://localhost'); -$mutex = new PHPRedisMutex([$redis], 'balance'); -$mutex->synchronized(function () use ($bankAccount, $amount) { - $balance = $bankAccount->getBalance(); - $balance -= $amount; - if ($balance < 0) { - throw new \DomainException('You have no credit'); - } - $bankAccount->setBalance($balance); -}); -``` - -#### PredisMutex - -The **PredisMutex** is the distributed lock implementation of -[RedLock](http://redis.io/topics/distlock) which uses the -[`Predis` API](https://github.com/nrk/predis). - -Example: -```php -$redis = new \Predis\Client('redis://localhost'); - -$mutex = new PredisMutex([$redis], 'balance'); +$mutex = new RedisMutex([$redis], 'balance'); $mutex->synchronized(function () use ($bankAccount, $amount) { $balance = $bankAccount->getBalance(); $balance -= $amount; @@ -316,6 +296,8 @@ The **MySQLMutex** uses MySQL's [`GET_LOCK`](https://dev.mysql.com/doc/refman/9.0/en/locking-functions.html#function_get-lock) function. +Both MySQL and MariaDB servers are supported. + It supports timeouts. If the connection to the database server is lost or interrupted, the lock is automatically released. @@ -366,6 +348,14 @@ $mutex->synchronized(function () use ($bankAccount, $amount) { }); ``` +## Authors + +Since year 2015 the development was led by Markus Malkusch, Willem Stuursma-Ruwen, Michael Voříšek and many GitHub contributors. + +Currently this library is maintained by Michael Voříšek - [GitHub][https://github.com/mvorisek] and [LinkedIn][https://www.linkedin.com/mvorisek]. + +Commercial support is available. + ## License This project is free and is licensed under the MIT. diff --git a/phpstan.neon.dist b/phpstan.neon.dist index 5ea53027..0b72517d 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -12,7 +12,7 @@ parameters: ignoreErrors: # TODO - - path: 'src/Mutex/RedisMutex.php' + path: 'src/Mutex/AbstractRedlockMutex.php' identifier: if.condNotBoolean message: '~^Only booleans are allowed in an if condition, mixed given\.$~' count: 1 @@ -21,16 +21,6 @@ parameters: identifier: if.condNotBoolean message: '~^Only booleans are allowed in an if condition, mixed given\.$~' count: 1 - - - message: '~^Parameter #1 \$(redisAPI|redis) \(Redis\|RedisCluster\) of method Malkusch\\Lock\\Mutex\\PHPRedisMutex::(add|evalScript)\(\) should be contravariant with parameter \$redisAPI \(mixed\) of method Malkusch\\Lock\\Mutex\\RedisMutex::(add|evalScript)\(\)$~' - identifier: method.childParameterType - path: 'src/Mutex/PHPRedisMutex.php' - count: 2 - - - message: '~^Parameter #1 \$(redisAPI|client) \(Predis\\ClientInterface\) of method Malkusch\\Lock\\Mutex\\PredisMutex::(add|evalScript)\(\) should be contravariant with parameter \$redisAPI \(mixed\) of method Malkusch\\Lock\\Mutex\\RedisMutex::(add|evalScript)\(\)$~' - identifier: method.childParameterType - path: 'src/Mutex/PredisMutex.php' - count: 2 - path: 'tests/Mutex/*Test.php' identifier: empty.notAllowed diff --git a/src/Mutex/AbstractRedlockMutex.php b/src/Mutex/AbstractRedlockMutex.php new file mode 100644 index 00000000..248b17bd --- /dev/null +++ b/src/Mutex/AbstractRedlockMutex.php @@ -0,0 +1,173 @@ + */ + private $clients; + + /** + * Sets the Redis APIs. + * + * The Redis APIs needs to be connected. I.e. Redis::connect() was + * called already. + * + * @param array $clients + * @param float $timeout The timeout in seconds a lock expires + * + * @throws \LengthException The timeout must be greater than 0 + */ + public function __construct(array $clients, string $name, float $timeout = 3) + { + parent::__construct($name, $timeout); + + $this->clients = $clients; + $this->logger = new NullLogger(); + } + + #[\Override] + protected function acquire(string $key, float $expire): bool + { + // 1. This differs from the specification to avoid an overflow on 32-Bit systems. + $time = microtime(true); + + // 2. + $acquired = 0; + $errored = 0; + $this->token = LockUtil::getInstance()->makeRandomToken(); + $exception = null; + foreach ($this->clients as $index => $client) { + try { + if ($this->add($client, $key, $this->token, $expire)) { + ++$acquired; + } + } catch (LockAcquireException $exception) { + // todo if there is only one redis server, throw immediately. + $context = [ + 'key' => $key, + 'index' => $index, + 'token' => $this->token, + 'exception' => $exception, + ]; + $this->logger->warning('Could not set {key} = {token} at server #{index}', $context); + + ++$errored; + } + } + + // 3. + $elapsedTime = microtime(true) - $time; + $isAcquired = $this->isMajority($acquired) && $elapsedTime <= $expire; + + if ($isAcquired) { + // 4. + return true; + } + + // 5. + $this->release($key); + + // In addition to RedLock it's an exception if too many servers fail. + if (!$this->isMajority(count($this->clients) - $errored)) { + assert($exception !== null); // The last exception for some context. + + throw new LockAcquireException( + 'It\'s not possible to acquire a lock because at least half of the Redis server are not available', + LockAcquireException::REDIS_NOT_ENOUGH_SERVERS, + $exception + ); + } + + return false; + } + + #[\Override] + protected function release(string $key): bool + { + /* + * All Redis commands must be analyzed before execution to determine which keys the command will operate on. In + * order for this to be true for EVAL, keys must be passed explicitly. + * + * @link https://redis.io/commands/set + */ + $script = 'if redis.call("get", KEYS[1]) == ARGV[1] then + return redis.call("del", KEYS[1]) + else + return 0 + end + '; + $released = 0; + foreach ($this->clients as $index => $client) { + try { + if ($this->evalScript($client, $script, 1, [$key, $this->token])) { + ++$released; + } + } catch (LockReleaseException $e) { + // todo throw if there is only one redis server + $context = [ + 'key' => $key, + 'index' => $index, + 'token' => $this->token, + 'exception' => $e, + ]; + $this->logger->warning('Could not unset {key} = {token} at server #{index}', $context); + } + } + + return $this->isMajority($released); + } + + /** + * Returns if a count is the majority of all servers. + * + * @return bool True if the count is the majority + */ + private function isMajority(int $count): bool + { + return $count > count($this->clients) / 2; + } + + /** + * Sets the key only if such key doesn't exist at the server yet. + * + * @param TClient $client + * @param float $expire The TTL seconds + * + * @return bool True if the key was set + */ + abstract protected function add($client, string $key, string $value, float $expire): bool; + + /** + * @param TClient $client + * @param string $script The Lua script + * @param int $numkeys The number of values in $arguments that represent Redis key names + * @param list $arguments Keys and values + * + * @return mixed The script result, or false if executing failed + * + * @throws LockReleaseException An unexpected error happened + */ + abstract protected function evalScript($client, string $script, int $numkeys, array $arguments); +} diff --git a/src/Mutex/PHPRedisMutex.php b/src/Mutex/PHPRedisMutex.php deleted file mode 100644 index f02096bb..00000000 --- a/src/Mutex/PHPRedisMutex.php +++ /dev/null @@ -1,107 +0,0 @@ - $redisAPIs - * @param float $timeout The timeout in seconds a lock expires - * - * @throws \LengthException The timeout must be greater than 0 - */ - public function __construct(array $redisAPIs, string $name, float $timeout = 3) - { - parent::__construct($redisAPIs, $name, $timeout); - } - - /** - * @param \Redis|\RedisCluster $redisAPI - * - * @throws LockAcquireException - */ - #[\Override] - protected function add($redisAPI, string $key, string $value, float $expire): bool - { - $expireMillis = (int) ceil($expire * 1000); - - try { - // Will set the key, if it doesn't exist, with a ttl of $expire seconds - return $redisAPI->set($key, $value, ['nx', 'px' => $expireMillis]); - } catch (\RedisException $e) { - $message = sprintf( - 'Failed to acquire lock for key \'%s\'', - $key - ); - - throw new LockAcquireException($message, 0, $e); - } - } - - /** - * @param \Redis|\RedisCluster $redis - */ - #[\Override] - protected function evalScript($redis, string $script, int $numkeys, array $arguments) - { - for ($i = $numkeys; $i < count($arguments); ++$i) { - /* - * If a serialization mode such as "php" or "igbinary" is enabled, the arguments must be - * serialized by us, because phpredis does not do this for the eval command. - * - * The keys must not be serialized. - */ - $arguments[$i] = $redis->_serialize($arguments[$i]); - - /* - * If LZF compression is enabled for the redis connection and the runtime has the LZF - * extension installed, compress the arguments as the final step. - */ - if ($this->hasLzfCompression($redis)) { - $arguments[$i] = lzf_compress($arguments[$i]); - } - } - - try { - return $redis->eval($script, $arguments, $numkeys); - } catch (\RedisException $e) { - throw new LockReleaseException('Failed to release lock', 0, $e); - } - } - - /** - * Determines if lzf compression is enabled for the given connection. - * - * @param \Redis|\RedisCluster $redis - * - * @return bool True if lzf compression is enabled, false otherwise - */ - private function hasLzfCompression($redis): bool - { - if (!\defined('Redis::COMPRESSION_LZF')) { - return false; - } - - return $redis->getOption(\Redis::OPT_COMPRESSION) === \Redis::COMPRESSION_LZF; - } -} diff --git a/src/Mutex/PredisMutex.php b/src/Mutex/PredisMutex.php deleted file mode 100644 index 30030612..00000000 --- a/src/Mutex/PredisMutex.php +++ /dev/null @@ -1,66 +0,0 @@ -set($key, $value, 'PX', $expireMillis, 'NX') !== null; - } catch (PredisException $e) { - $message = sprintf( - 'Failed to acquire lock for key \'%s\'', - $key - ); - - throw new LockAcquireException($message, 0, $e); - } - } - - /** - * @param ClientInterface $client - */ - #[\Override] - protected function evalScript($client, string $script, int $numkeys, array $arguments) - { - try { - return $client->eval($script, $numkeys, ...$arguments); - } catch (PredisException $e) { - throw new LockReleaseException('Failed to release lock', 0, $e); - } - } -} diff --git a/src/Mutex/RedisMutex.php b/src/Mutex/RedisMutex.php index f5c6b56c..8f6f1089 100644 --- a/src/Mutex/RedisMutex.php +++ b/src/Mutex/RedisMutex.php @@ -6,163 +6,113 @@ use Malkusch\Lock\Exception\LockAcquireException; use Malkusch\Lock\Exception\LockReleaseException; -use Malkusch\Lock\Util\LockUtil; -use Psr\Log\LoggerAwareInterface; -use Psr\Log\LoggerAwareTrait; -use Psr\Log\NullLogger; +use Predis\ClientInterface as PredisClientInterface; +use Predis\PredisException; /** - * Mutex based on the Redlock algorithm. + * Distributed mutex based on the Redlock algorithm supporting the phpredis extension and Predis API. + * + * @phpstan-type TClient \Redis|\RedisCluster|PredisClientInterface + * + * @extends AbstractRedlockMutex * * @see http://redis.io/topics/distlock */ -abstract class RedisMutex extends AbstractSpinlockMutex implements LoggerAwareInterface +class RedisMutex extends AbstractRedlockMutex { - use LoggerAwareTrait; - - /** @var string The random value token for key identification */ - private $token; - - /** @var array */ - private $redisAPIs; - /** - * Sets the Redis APIs. - * - * @param array $redisAPIs - * @param float $timeout The timeout in seconds a lock expires + * @param TClient $client * - * @throws \LengthException The timeout must be greater than 0 + * @phpstan-assert-if-true \Redis|\RedisCluster $client */ - public function __construct(array $redisAPIs, string $name, float $timeout = 3) + private function isClientPHPRedis($client): bool { - parent::__construct($name, $timeout); + $res = $client instanceof \Redis || $client instanceof \RedisCluster; - $this->redisAPIs = $redisAPIs; - $this->logger = new NullLogger(); + \assert($res === !$client instanceof PredisClientInterface); + + return $res; } + /** + * @throws LockAcquireException + */ #[\Override] - protected function acquire(string $key, float $expire): bool + protected function add($client, string $key, string $value, float $expire): bool { - // 1. This differs from the specification to avoid an overflow on 32-Bit systems. - $time = microtime(true); + $expireMillis = (int) ceil($expire * 1000); - // 2. - $acquired = 0; - $errored = 0; - $this->token = LockUtil::getInstance()->makeRandomToken(); - $exception = null; - foreach ($this->redisAPIs as $index => $redisAPI) { + if ($this->isClientPHPRedis($client)) { try { - if ($this->add($redisAPI, $key, $this->token, $expire)) { - ++$acquired; - } - } catch (LockAcquireException $exception) { - // todo if there is only one redis server, throw immediately. - $context = [ - 'key' => $key, - 'index' => $index, - 'token' => $this->token, - 'exception' => $exception, - ]; - $this->logger->warning('Could not set {key} = {token} at server #{index}', $context); - - ++$errored; + // Will set the key, if it doesn't exist, with a ttl of $expire seconds + return $client->set($key, $value, ['nx', 'px' => $expireMillis]); + } catch (\RedisException $e) { + $message = sprintf( + 'Failed to acquire lock for key \'%s\'', + $key + ); + + throw new LockAcquireException($message, 0, $e); + } + } else { + try { + return $client->set($key, $value, 'PX', $expireMillis, 'NX') !== null; + } catch (PredisException $e) { + $message = sprintf( + 'Failed to acquire lock for key \'%s\'', + $key + ); + + throw new LockAcquireException($message, 0, $e); } } - - // 3. - $elapsedTime = microtime(true) - $time; - $isAcquired = $this->isMajority($acquired) && $elapsedTime <= $expire; - - if ($isAcquired) { - // 4. - return true; - } - - // 5. - $this->release($key); - - // In addition to RedLock it's an exception if too many servers fail. - if (!$this->isMajority(count($this->redisAPIs) - $errored)) { - assert($exception !== null); // The last exception for some context. - - throw new LockAcquireException( - 'It\'s not possible to acquire a lock because at least half of the Redis server are not available', - LockAcquireException::REDIS_NOT_ENOUGH_SERVERS, - $exception - ); - } - - return false; } #[\Override] - protected function release(string $key): bool + protected function evalScript($client, string $script, int $numkeys, array $arguments) { - /* - * All Redis commands must be analyzed before execution to determine which keys the command will operate on. In - * order for this to be true for EVAL, keys must be passed explicitly. - * - * @link https://redis.io/commands/set - */ - $script = 'if redis.call("get", KEYS[1]) == ARGV[1] then - return redis.call("del", KEYS[1]) - else - return 0 - end - '; - $released = 0; - foreach ($this->redisAPIs as $index => $redisAPI) { - try { - if ($this->evalScript($redisAPI, $script, 1, [$key, $this->token])) { - ++$released; + if ($this->isClientPHPRedis($client)) { + for ($i = $numkeys; $i < count($arguments); ++$i) { + /* + * If a serialization mode such as "php" or "igbinary" is enabled, the arguments must be + * serialized by us, because phpredis does not do this for the eval command. + * + * The keys must not be serialized. + */ + $arguments[$i] = $client->_serialize($arguments[$i]); + + /* + * If LZF compression is enabled for the redis connection and the runtime has the LZF + * extension installed, compress the arguments as the final step. + */ + if ($this->isLzfCompressionEnabled($client)) { + $arguments[$i] = lzf_compress($arguments[$i]); } - } catch (LockReleaseException $e) { - // todo throw if there is only one redis server - $context = [ - 'key' => $key, - 'index' => $index, - 'token' => $this->token, - 'exception' => $e, - ]; - $this->logger->warning('Could not unset {key} = {token} at server #{index}', $context); } - } - return $this->isMajority($released); + try { + return $client->eval($script, $arguments, $numkeys); + } catch (\RedisException $e) { + throw new LockReleaseException('Failed to release lock', 0, $e); + } + } else { + try { + return $client->eval($script, $numkeys, ...$arguments); + } catch (PredisException $e) { + throw new LockReleaseException('Failed to release lock', 0, $e); + } + } } /** - * Returns if a count is the majority of all servers. - * - * @return bool True if the count is the majority + * @param \Redis|\RedisCluster $client */ - private function isMajority(int $count): bool + private function isLzfCompressionEnabled($client): bool { - return $count > count($this->redisAPIs) / 2; - } - - /** - * Sets the key only if such key doesn't exist at the server yet. - * - * @param mixed $redisAPI - * @param float $expire The TTL seconds - * - * @return bool True if the key was set - */ - abstract protected function add($redisAPI, string $key, string $value, float $expire): bool; + if (!\defined('Redis::COMPRESSION_LZF')) { + return false; + } - /** - * @param mixed $redisAPI - * @param string $script The Lua script - * @param int $numkeys The number of values in $arguments that represent Redis key names - * @param list $arguments Keys and values - * - * @return mixed The script result, or false if executing failed - * - * @throws LockReleaseException An unexpected error happened - */ - abstract protected function evalScript($redisAPI, string $script, int $numkeys, array $arguments); + return $client->getOption(\Redis::OPT_COMPRESSION) === \Redis::COMPRESSION_LZF; + } } diff --git a/tests/Mutex/AbstractRedlockMutexTest.php b/tests/Mutex/AbstractRedlockMutexTest.php new file mode 100644 index 00000000..20dbf963 --- /dev/null +++ b/tests/Mutex/AbstractRedlockMutexTest.php @@ -0,0 +1,339 @@ +addNamespace(__NAMESPACE__); + $sleepBuilder->addNamespace('Malkusch\Lock\Mutex'); + $sleepBuilder->addNamespace('Malkusch\Lock\Util'); + $sleep = $sleepBuilder->build(); + try { + $sleep->enable(); + $this->registerForTearDown($sleep); + } catch (MockEnabledException $e) { + // workaround for burn testing + \assert($e->getMessage() === 'microtime is already enabled.Call disable() on the existing mock.'); + } + } + + /** + * @param int $count The amount of redis APIs + * + * @return AbstractRedlockMutex&MockObject + */ + private function createRedlockMutexMock(int $count, float $timeout = 1): AbstractRedlockMutex + { + $clients = array_map( + static fn ($id) => ['id' => $id], + range(1, $count) + ); + + return $this->getMockBuilder(AbstractRedlockMutex::class) + ->setConstructorArgs([$clients, 'test', $timeout]) + ->onlyMethods(['add', 'evalScript']) + ->getMock(); + } + + /** + * Tests acquire() fails because too few servers are available. + * + * @param int $count The total count of servers + * @param int $available The count of available servers + * + * @dataProvider provideMinorityCases + */ + #[DataProvider('provideMinorityCases')] + public function testTooFewServerToAcquire(int $count, int $available): void + { + $this->expectException(LockAcquireException::class); + $this->expectExceptionCode(MutexException::REDIS_NOT_ENOUGH_SERVERS); + + $mutex = $this->createRedlockMutexMock($count); + + $i = 0; + $mutex->expects(self::exactly($count)) + ->method('add') + ->willReturnCallback( + static function () use (&$i, $available): bool { + if ($i < $available) { + ++$i; + + return true; + } + + throw new LockAcquireException(); + } + ); + + $mutex->synchronized(static function (): void { + self::fail('Code should not be executed'); + }); + } + + /** + * Tests synchronized() does work if the majority of servers is up. + * + * @param int $count The total count of servers + * @param int $available The count of available servers + * + * @dataProvider provideMajorityCases + */ + #[DataProvider('provideMajorityCases')] + public function testFaultTolerance(int $count, int $available): void + { + $mutex = $this->createRedlockMutexMock($count); + $mutex->expects(self::exactly($count)) + ->method('evalScript') + ->willReturn(true); + + $i = 0; + $mutex->expects(self::exactly($count)) + ->method('add') + ->willReturnCallback( + static function () use (&$i, $available): bool { + if ($i < $available) { + ++$i; + + return true; + } + + throw new LockAcquireException(); + } + ); + + $mutex->synchronized(static function () {}); + } + + /** + * Tests too few keys could be acquired. + * + * @param int $count The total count of servers + * @param int $available The count of available servers + * + * @dataProvider provideMinorityCases + */ + #[DataProvider('provideMinorityCases')] + public function testAcquireTooFewKeys(int $count, int $available): void + { + $this->expectException(TimeoutException::class); + $this->expectExceptionMessage('Timeout of 1.0 seconds exceeded'); + + $mutex = $this->createRedlockMutexMock($count); + + $i = 0; + $mutex->expects(self::any()) + ->method('add') + ->willReturnCallback( + static function () use (&$i, $available): bool { + ++$i; + + return $i <= $available; + } + ); + + $mutex->synchronized(static function (): void { + self::fail('Code should not be executed'); + }); + } + + /** + * Tests acquiring keys takes too long. + * + * @param int $count The total count of servers + * @param float $timeout The timeout in seconds + * @param float $delay The delay in seconds + * + * @dataProvider provideTimingOutCases + */ + #[DataProvider('provideTimingOutCases')] + public function testTimingOut(int $count, float $timeout, float $delay): void + { + $timeoutStr = (string) round($timeout, 6); + if (strpos($timeoutStr, '.') === false) { + $timeoutStr .= '.0'; + } + + $this->expectException(TimeoutException::class); + $this->expectExceptionMessage('Timeout of ' . $timeoutStr . ' seconds exceeded'); + + $mutex = $this->createRedlockMutexMock($count, $timeout); + + $mutex->expects(self::exactly($count)) + ->method('add') + ->willReturnCallback(static function () use ($delay): bool { + usleep((int) ($delay * 1e6)); + + return true; + }); + + $mutex->synchronized(static function (): void { + self::fail('Code should not be executed'); + }); + } + + /** + * @return iterable> + */ + public static function provideTimingOutCases(): iterable + { + yield [1, 1.2 - 1, 1.201]; + yield [2, 1.2 - 1, 1.401]; + } + + /** + * Tests synchronized() works if the majority of keys was acquired. + * + * @param int $count The total count of servers + * @param int $available The count of available servers + * + * @dataProvider provideMajorityCases + */ + #[DataProvider('provideMajorityCases')] + public function testAcquireWithMajority(int $count, int $available): void + { + $mutex = $this->createRedlockMutexMock($count); + $mutex->expects(self::exactly($count)) + ->method('evalScript') + ->willReturn(true); + + $i = 0; + $mutex->expects(self::exactly($count)) + ->method('add') + ->willReturnCallback( + static function () use (&$i, $available): bool { + ++$i; + + return $i <= $available; + } + ); + + $mutex->synchronized(static function (): void {}); + } + + /** + * Tests releasing fails because too few servers are available. + * + * @param int $count The total count of servers + * @param int $available The count of available servers + * + * @dataProvider provideMinorityCases + */ + #[DataProvider('provideMinorityCases')] + public function testTooFewServersToRelease(int $count, int $available): void + { + $mutex = $this->createRedlockMutexMock($count); + $mutex->expects(self::exactly($count)) + ->method('add') + ->willReturn(true); + + $i = 0; + $mutex->expects(self::exactly($count)) + ->method('evalScript') + ->willReturnCallback( + static function () use (&$i, $available): bool { + if ($i < $available) { + ++$i; + + return true; + } + + throw new LockReleaseException(); + } + ); + + $this->expectException(LockReleaseException::class); + + $mutex->synchronized(static function (): void {}); + } + + /** + * Tests releasing too few keys. + * + * @param int $count The total count of servers + * @param int $available The count of available servers + * + * @dataProvider provideMinorityCases + */ + #[DataProvider('provideMinorityCases')] + public function testReleaseTooFewKeys(int $count, int $available): void + { + $mutex = $this->createRedlockMutexMock($count); + $mutex->expects(self::exactly($count)) + ->method('add') + ->willReturn(true); + + $i = 0; + $mutex->expects(self::exactly($count)) + ->method('evalScript') + ->willReturnCallback( + static function () use (&$i, $available): bool { + ++$i; + + return $i <= $available; + } + ); + + $this->expectException(LockReleaseException::class); + + $mutex->synchronized(static function (): void {}); + } + + /** + * Provides test cases with too few. + * + * @return iterable> + */ + public static function provideMinorityCases(): iterable + { + yield [1, 0]; + yield [2, 0]; + yield [2, 1]; + yield [3, 0]; + yield [3, 1]; + yield [4, 0]; + yield [4, 1]; + yield [4, 2]; + } + + /** + * Provides test cases with enough. + * + * @return iterable> + */ + public static function provideMajorityCases(): iterable + { + yield [1, 1]; + yield [2, 2]; + yield [3, 2]; + yield [3, 3]; + yield [4, 3]; + } +} diff --git a/tests/Mutex/AbstractSpinlockMutexTest.php b/tests/Mutex/AbstractSpinlockMutexTest.php index 926219e9..05758faa 100644 --- a/tests/Mutex/AbstractSpinlockMutexTest.php +++ b/tests/Mutex/AbstractSpinlockMutexTest.php @@ -41,7 +41,7 @@ protected function setUp(): void /** * @return AbstractSpinlockMutex&MockObject */ - private function createAbstractSpinlockMutexMock(float $timeout = 3): AbstractSpinlockMutex + private function createSpinlockMutexMock(float $timeout = 3): AbstractSpinlockMutex { return $this->getMockBuilder(AbstractSpinlockMutex::class) ->setConstructorArgs(['test', $timeout]) @@ -56,7 +56,7 @@ public function testFailAcquireLock(): void { $this->expectException(LockAcquireException::class); - $mutex = $this->createAbstractSpinlockMutexMock(); + $mutex = $this->createSpinlockMutexMock(); $mutex->expects(self::any()) ->method('acquire') ->willThrowException(new LockAcquireException()); @@ -74,7 +74,7 @@ public function testAcquireTimesOut(): void $this->expectException(TimeoutException::class); $this->expectExceptionMessage('Timeout of 3.0 seconds exceeded'); - $mutex = $this->createAbstractSpinlockMutexMock(); + $mutex = $this->createSpinlockMutexMock(); $mutex->expects(self::atLeastOnce()) ->method('acquire') ->willReturn(false); @@ -89,7 +89,7 @@ public function testAcquireTimesOut(): void */ public function testExecuteTooLong(): void { - $mutex = $this->createAbstractSpinlockMutexMock(0.5); + $mutex = $this->createSpinlockMutexMock(0.5); $mutex->expects(self::any()) ->method('acquire') ->willReturn(true); @@ -114,7 +114,7 @@ public function testExecuteTooLong(): void */ public function testExecuteBarelySucceeds(): void { - $mutex = $this->createAbstractSpinlockMutexMock(0.5); + $mutex = $this->createSpinlockMutexMock(0.5); $mutex->expects(self::any())->method('acquire')->willReturn(true); $mutex->expects(self::once())->method('release')->willReturn(true); @@ -130,7 +130,7 @@ public function testFailReleasingLock(): void { $this->expectException(LockReleaseException::class); - $mutex = $this->createAbstractSpinlockMutexMock(); + $mutex = $this->createSpinlockMutexMock(); $mutex->expects(self::any())->method('acquire')->willReturn(true); $mutex->expects(self::any())->method('release')->willReturn(false); @@ -142,7 +142,7 @@ public function testFailReleasingLock(): void */ public function testExecuteTimeoutLeavesOneSecondForKeyToExpire(): void { - $mutex = $this->createAbstractSpinlockMutexMock(0.2); + $mutex = $this->createSpinlockMutexMock(0.2); $mutex->expects(self::once()) ->method('acquire') ->with(self::anything(), 1.2) diff --git a/tests/Mutex/MutexConcurrencyTest.php b/tests/Mutex/MutexConcurrencyTest.php index 970ea195..f91e70cb 100644 --- a/tests/Mutex/MutexConcurrencyTest.php +++ b/tests/Mutex/MutexConcurrencyTest.php @@ -9,16 +9,15 @@ use Malkusch\Lock\Mutex\MemcachedMutex; use Malkusch\Lock\Mutex\Mutex; use Malkusch\Lock\Mutex\MySQLMutex; -use Malkusch\Lock\Mutex\PHPRedisMutex; use Malkusch\Lock\Mutex\PostgreSQLMutex; -use Malkusch\Lock\Mutex\PredisMutex; +use Malkusch\Lock\Mutex\RedisMutex; use Malkusch\Lock\Mutex\SemaphoreMutex; use Malkusch\Lock\Mutex\TransactionalMutex; use Malkusch\Lock\Util\LockUtil; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Constraint\IsType; use PHPUnit\Framework\TestCase; -use Predis\Client; +use Predis\Client as PredisClient; use Spatie\Async\Pool; /** @@ -278,19 +277,19 @@ public static function provideExecutionIsSerializedWhenLockedCases(): iterable if (getenv('REDIS_URIS')) { $uris = explode(',', getenv('REDIS_URIS')); - yield 'PredisMutex' => [static function ($timeout) use ($uris): Mutex { + yield 'RedisMutex /w Predis' => [static function ($timeout) use ($uris): Mutex { $clients = array_map( - static fn ($uri) => new Client($uri), + static fn ($uri) => new PredisClient($uri), $uris ); - return new PredisMutex($clients, 'test', $timeout); + return new RedisMutex($clients, 'test', $timeout); }]; if (class_exists(\Redis::class)) { - yield 'PHPRedisMutex' => [ + yield 'RedisMutex /w PHPRedis' => [ static function ($timeout) use ($uris): Mutex { - $apis = array_map( + $clients = array_map( static function (string $uri): \Redis { $redis = new \Redis(); @@ -309,7 +308,7 @@ static function (string $uri): \Redis { $uris ); - return new PHPRedisMutex($apis, 'test', $timeout); + return new RedisMutex($clients, 'test', $timeout); }, ]; } diff --git a/tests/Mutex/MutexTest.php b/tests/Mutex/MutexTest.php index 58dd6b93..6898464b 100644 --- a/tests/Mutex/MutexTest.php +++ b/tests/Mutex/MutexTest.php @@ -12,16 +12,15 @@ use Malkusch\Lock\Mutex\Mutex; use Malkusch\Lock\Mutex\MySQLMutex; use Malkusch\Lock\Mutex\NoMutex; -use Malkusch\Lock\Mutex\PHPRedisMutex; use Malkusch\Lock\Mutex\PostgreSQLMutex; -use Malkusch\Lock\Mutex\PredisMutex; +use Malkusch\Lock\Mutex\RedisMutex; use Malkusch\Lock\Mutex\SemaphoreMutex; use Malkusch\Lock\Mutex\TransactionalMutex; use org\bovigo\vfs\vfsStream; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\DoesNotPerformAssertions; use PHPUnit\Framework\TestCase; -use Predis\Client; +use Predis\Client as PredisClient; /** * If you want to run integrations tests you should provide these environment variables: @@ -129,19 +128,19 @@ protected function unlock(): void {} if (getenv('REDIS_URIS')) { $uris = explode(',', getenv('REDIS_URIS')); - yield 'PredisMutex' => [static function () use ($uris): Mutex { + yield 'RedisMutex /w Predis' => [static function () use ($uris): Mutex { $clients = array_map( - static fn ($uri) => new Client($uri), + static fn ($uri) => new PredisClient($uri), $uris ); - return new PredisMutex($clients, 'test', self::TIMEOUT); + return new RedisMutex($clients, 'test', self::TIMEOUT); }]; if (class_exists(\Redis::class)) { - yield 'PHPRedisMutex' => [ + yield 'RedisMutex /w PHPRedis' => [ static function () use ($uris): Mutex { - $apis = array_map( + $clients = array_map( static function ($uri) { $redis = new \Redis(); @@ -160,7 +159,7 @@ static function ($uri) { $uris ); - return new PHPRedisMutex($apis, 'test', self::TIMEOUT); + return new RedisMutex($clients, 'test', self::TIMEOUT); }, ]; } diff --git a/tests/Mutex/PHPRedisMutexTest.php b/tests/Mutex/PHPRedisMutexTest.php deleted file mode 100644 index a616d872..00000000 --- a/tests/Mutex/PHPRedisMutexTest.php +++ /dev/null @@ -1,288 +0,0 @@ -= 8) { - trait RedisTestTrait - { - /** - * @param list $args - */ - #[\Override] // @phpstan-ignore method.childParameterType - public function eval($script, $args = [], $numKeys = 0): mixed - { - return $this->_eval($script, $args, $numKeys); - } - - /** - * @param mixed $options - */ - #[\Override] - public function set($key, $value, $options = null): /* \Redis|string| */ bool - { - return $this->_set($key, $value, $options); - } - } -} else { - trait RedisTestTrait - { - /** - * @return mixed - */ - #[\Override] - public function eval($script, $args = [], $numKeys = 0) - { - return $this->_eval($script, $args, $numKeys); - } - - /** - * @return \Redis|string|bool - */ - #[\Override] - public function set($key, $value, $options = null) - { - return $this->_set($key, $value, $options); - } - } -} - -/** - * These tests require the environment variable: - * - * REDIS_URIS - a comma separated list of redis:// URIs. - * - * @requires extension redis - * - * @group redis - */ -#[RequiresPhpExtension('redis')] -#[Group('redis')] -class PHPRedisMutexTest extends TestCase -{ - /** @var \Redis[] */ - private $connections = []; - - /** @var PHPRedisMutex */ - private $mutex; - - #[\Override] - protected function setUp(): void - { - parent::setUp(); - - if (!getenv('REDIS_URIS')) { - self::markTestSkipped('Redis server is needed'); - } - - $redisUris = explode(',', getenv('REDIS_URIS')); - - foreach ($redisUris as $redisUri) { - $uri = parse_url($redisUri); - - // original Redis::set and Redis::eval calls will reopen the connection - $connection = new class extends \Redis { - use RedisTestTrait; - - /** @var bool */ - private $is_closed = false; - - #[\Override] - public function close(): bool - { - $res = parent::close(); - $this->is_closed = true; - - return $res; - } - - /** - * @param mixed $value - * @param mixed $timeout - * - * @return \Redis|string|bool - */ - private function _set(string $key, $value, $timeout = 0) - { - if ($this->is_closed) { - throw new \RedisException('Connection is closed'); - } - - return parent::set($key, $value, $timeout); - } - - /** - * @param list $args - * - * @return mixed - */ - private function _eval(string $script, array $args = [], int $numKeys = 0) - { - if ($this->is_closed) { - throw new \RedisException('Connection is closed'); - } - - return parent::eval($script, $args, $numKeys); - } - }; - - $connection->connect($uri['host'], $uri['port'] ?? 6379); - if (!empty($uri['pass'])) { - $connection->auth( - empty($uri['user']) - ? $uri['pass'] - : [$uri['user'], $uri['pass']] - ); - } - - $connection->flushAll(); // Clear any existing locks. - - $this->connections[] = $connection; - } - - $this->mutex = new PHPRedisMutex($this->connections, 'test'); - } - - #[\Override] - protected function assertPostConditions(): void - { - // workaround for burn testing - $this->connections = []; - - parent::assertPostConditions(); - } - - private function closeMajorityConnections(): void - { - $numberToClose = (int) ceil(count($this->connections) / 2); - - foreach ((array) array_rand($this->connections, $numberToClose) as $keyToClose) { - $this->connections[$keyToClose]->close(); - } - } - - private function closeMinorityConnections(): void - { - if (count($this->connections) === 1) { - self::markTestSkipped('Cannot test this with only a single Redis server'); - } - - $numberToClose = (int) ceil(count($this->connections) / 2) - 1; - if (0 >= $numberToClose) { - return; - } - - foreach ((array) array_rand($this->connections, $numberToClose) as $keyToClose) { - $this->connections[$keyToClose]->close(); - } - } - - public function testAddFails(): void - { - $this->expectException(LockAcquireException::class); - $this->expectExceptionCode(MutexException::REDIS_NOT_ENOUGH_SERVERS); - - $this->closeMajorityConnections(); - - $this->mutex->synchronized(static function (): void { - self::fail('Code execution is not expected'); - }); - } - - /** - * Tests evalScript() fails. - */ - public function testEvalScriptFails(): void - { - $this->expectException(LockReleaseException::class); - - $this->mutex->synchronized(function (): void { - $this->closeMajorityConnections(); - }); - } - - /** - * @param \Redis::SERIALIZER_* $serializer - * @param \Redis::COMPRESSION_* $compressor - * - * @dataProvider provideSerializersAndCompressorsCases - */ - #[DataProvider('provideSerializersAndCompressorsCases')] - public function testSerializersAndCompressors(int $serializer, int $compressor): void - { - foreach ($this->connections as $connection) { - $connection->setOption(\Redis::OPT_SERIALIZER, $serializer); - $connection->setOption(\Redis::OPT_COMPRESSION, $compressor); - } - - self::assertSame('test', $this->mutex->synchronized(static function (): string { - return 'test'; - })); - } - - public function testResistantToPartialClusterFailuresForAcquiringLock(): void - { - $this->closeMinorityConnections(); - - self::assertSame('test', $this->mutex->synchronized(static function (): string { - return 'test'; - })); - } - - public function testResistantToPartialClusterFailuresForReleasingLock(): void - { - self::assertNull($this->mutex->synchronized(function () { // @phpstan-ignore staticMethod.alreadyNarrowedType - $this->closeMinorityConnections(); - - return null; - })); - } - - /** - * @return iterable> - */ - public static function provideSerializersAndCompressorsCases(): iterable - { - if (!class_exists(\Redis::class)) { - return; - } - - yield [\Redis::SERIALIZER_NONE, \Redis::COMPRESSION_NONE]; - yield [\Redis::SERIALIZER_PHP, \Redis::COMPRESSION_NONE]; - - if (defined('Redis::SERIALIZER_IGBINARY') && extension_loaded('igbinary')) { - yield [ - constant('Redis::SERIALIZER_IGBINARY'), - \Redis::COMPRESSION_NONE, - ]; - } - - if (defined('Redis::COMPRESSION_LZF') && extension_loaded('lzf')) { - yield [ - \Redis::SERIALIZER_NONE, - constant('Redis::COMPRESSION_LZF'), - ]; - yield [ - \Redis::SERIALIZER_PHP, - constant('Redis::COMPRESSION_LZF'), - ]; - - if (defined('Redis::SERIALIZER_IGBINARY') && extension_loaded('igbinary')) { - yield [ - constant('Redis::SERIALIZER_IGBINARY'), - constant('Redis::COMPRESSION_LZF'), - ]; - } - } - } -} diff --git a/tests/Mutex/RedisMutexTest.php b/tests/Mutex/RedisMutexTest.php index 20ca0503..38e285cb 100644 --- a/tests/Mutex/RedisMutexTest.php +++ b/tests/Mutex/RedisMutexTest.php @@ -7,333 +7,282 @@ use Malkusch\Lock\Exception\LockAcquireException; use Malkusch\Lock\Exception\LockReleaseException; use Malkusch\Lock\Exception\MutexException; -use Malkusch\Lock\Exception\TimeoutException; use Malkusch\Lock\Mutex\RedisMutex; -use phpmock\environment\SleepEnvironmentBuilder; -use phpmock\MockEnabledException; -use phpmock\phpunit\PHPMock; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; -use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\Attributes\RequiresPhpExtension; use PHPUnit\Framework\TestCase; +if (\PHP_MAJOR_VERSION >= 8) { + trait RedisCompatibilityTrait + { + /** + * @param list $args + */ + #[\Override] // @phpstan-ignore method.childParameterType + public function eval($script, $args = [], $numKeys = 0): mixed + { + return $this->_eval($script, $args, $numKeys); + } + + /** + * @param mixed $options + */ + #[\Override] + public function set($key, $value, $options = null): /* \Redis|string| */ bool + { + return $this->_set($key, $value, $options); + } + } +} else { + trait RedisCompatibilityTrait + { + /** + * @return mixed + */ + #[\Override] + public function eval($script, $args = [], $numKeys = 0) + { + return $this->_eval($script, $args, $numKeys); + } + + /** + * @return \Redis|string|bool + */ + #[\Override] + public function set($key, $value, $options = null) + { + return $this->_set($key, $value, $options); + } + } +} + /** + * These tests require the environment variable: + * + * REDIS_URIS - a comma separated list of redis:// URIs. + * + * @requires extension redis + * * @group redis */ +#[RequiresPhpExtension('redis')] #[Group('redis')] class RedisMutexTest extends TestCase { - use PHPMock; + /** @var \Redis[] */ + private $connections = []; + + /** @var RedisMutex */ + private $mutex; #[\Override] protected function setUp(): void { parent::setUp(); - $sleepBuilder = new SleepEnvironmentBuilder(); - $sleepBuilder->addNamespace(__NAMESPACE__); - $sleepBuilder->addNamespace('Malkusch\Lock\Mutex'); - $sleepBuilder->addNamespace('Malkusch\Lock\Util'); - $sleep = $sleepBuilder->build(); - try { - $sleep->enable(); - $this->registerForTearDown($sleep); - } catch (MockEnabledException $e) { - // workaround for burn testing - \assert($e->getMessage() === 'microtime is already enabled.Call disable() on the existing mock.'); + if (!getenv('REDIS_URIS')) { + self::markTestSkipped('Redis server is needed'); } - } - /** - * @param int $count The amount of redis apis - * - * @return RedisMutex&MockObject - */ - private function createRedisMutexMock(int $count, float $timeout = 1): RedisMutex - { - $redisAPIs = array_map( - static fn ($id) => ['id' => $id], - range(1, $count) - ); - - return $this->getMockBuilder(RedisMutex::class) - ->setConstructorArgs([$redisAPIs, 'test', $timeout]) - ->onlyMethods(['add', 'evalScript']) - ->getMock(); - } + $redisUris = explode(',', getenv('REDIS_URIS')); - /** - * Tests acquire() fails because too few servers are available. - * - * @param int $count The total count of servers - * @param int $available The count of available servers - * - * @dataProvider provideMinorityCases - */ - #[DataProvider('provideMinorityCases')] - public function testTooFewServerToAcquire(int $count, int $available): void - { - $this->expectException(LockAcquireException::class); - $this->expectExceptionCode(MutexException::REDIS_NOT_ENOUGH_SERVERS); + foreach ($redisUris as $redisUri) { + $uri = parse_url($redisUri); - $mutex = $this->createRedisMutexMock($count); + // original Redis::set and Redis::eval calls will reopen the connection + $connection = new class extends \Redis { + use RedisCompatibilityTrait; - $i = 0; - $mutex->expects(self::exactly($count)) - ->method('add') - ->willReturnCallback( - static function () use (&$i, $available): bool { - if ($i < $available) { - ++$i; + /** @var bool */ + private $is_closed = false; - return true; - } + #[\Override] + public function close(): bool + { + $res = parent::close(); + $this->is_closed = true; - throw new LockAcquireException(); + return $res; } - ); - $mutex->synchronized(static function (): void { - self::fail('Code should not be executed'); - }); - } + /** + * @param mixed $value + * @param mixed $timeout + * + * @return \Redis|string|bool + */ + private function _set(string $key, $value, $timeout = 0) + { + if ($this->is_closed) { + throw new \RedisException('Connection is closed'); + } - /** - * Tests synchronized() does work if the majority of servers is up. - * - * @param int $count The total count of servers - * @param int $available The count of available servers - * - * @dataProvider provideMajorityCases - */ - #[DataProvider('provideMajorityCases')] - public function testFaultTolerance(int $count, int $available): void - { - $mutex = $this->createRedisMutexMock($count); - $mutex->expects(self::exactly($count)) - ->method('evalScript') - ->willReturn(true); - - $i = 0; - $mutex->expects(self::exactly($count)) - ->method('add') - ->willReturnCallback( - static function () use (&$i, $available): bool { - if ($i < $available) { - ++$i; - - return true; + return parent::set($key, $value, $timeout); + } + + /** + * @param list $args + * + * @return mixed + */ + private function _eval(string $script, array $args = [], int $numKeys = 0) + { + if ($this->is_closed) { + throw new \RedisException('Connection is closed'); } - throw new LockAcquireException(); + return parent::eval($script, $args, $numKeys); } - ); + }; - $mutex->synchronized(static function () {}); + $connection->connect($uri['host'], $uri['port'] ?? 6379); + if (!empty($uri['pass'])) { + $connection->auth( + empty($uri['user']) + ? $uri['pass'] + : [$uri['user'], $uri['pass']] + ); + } + + $connection->flushAll(); // Clear any existing locks. + + $this->connections[] = $connection; + } + + $this->mutex = new RedisMutex($this->connections, 'test'); } - /** - * Tests too few keys could be acquired. - * - * @param int $count The total count of servers - * @param int $available The count of available servers - * - * @dataProvider provideMinorityCases - */ - #[DataProvider('provideMinorityCases')] - public function testAcquireTooFewKeys(int $count, int $available): void + #[\Override] + protected function assertPostConditions(): void { - $this->expectException(TimeoutException::class); - $this->expectExceptionMessage('Timeout of 1.0 seconds exceeded'); + // workaround for burn testing + $this->connections = []; - $mutex = $this->createRedisMutexMock($count); - - $i = 0; - $mutex->expects(self::any()) - ->method('add') - ->willReturnCallback( - static function () use (&$i, $available): bool { - ++$i; + parent::assertPostConditions(); + } - return $i <= $available; - } - ); + private function closeMajorityConnections(): void + { + $numberToClose = (int) ceil(count($this->connections) / 2); - $mutex->synchronized(static function (): void { - self::fail('Code should not be executed'); - }); + foreach ((array) array_rand($this->connections, $numberToClose) as $keyToClose) { + $this->connections[$keyToClose]->close(); + } } - /** - * Tests acquiring keys takes too long. - * - * @param int $count The total count of servers - * @param float $timeout The timeout in seconds - * @param float $delay The delay in seconds - * - * @dataProvider provideTimingOutCases - */ - #[DataProvider('provideTimingOutCases')] - public function testTimingOut(int $count, float $timeout, float $delay): void + private function closeMinorityConnections(): void { - $timeoutStr = (string) round($timeout, 6); - if (strpos($timeoutStr, '.') === false) { - $timeoutStr .= '.0'; + if (count($this->connections) === 1) { + self::markTestSkipped('Cannot test this with only a single Redis server'); } - $this->expectException(TimeoutException::class); - $this->expectExceptionMessage('Timeout of ' . $timeoutStr . ' seconds exceeded'); + $numberToClose = (int) ceil(count($this->connections) / 2) - 1; + if (0 >= $numberToClose) { + return; + } - $mutex = $this->createRedisMutexMock($count, $timeout); + foreach ((array) array_rand($this->connections, $numberToClose) as $keyToClose) { + $this->connections[$keyToClose]->close(); + } + } - $mutex->expects(self::exactly($count)) - ->method('add') - ->willReturnCallback(static function () use ($delay): bool { - usleep((int) ($delay * 1e6)); + public function testAddFails(): void + { + $this->expectException(LockAcquireException::class); + $this->expectExceptionCode(MutexException::REDIS_NOT_ENOUGH_SERVERS); - return true; - }); + $this->closeMajorityConnections(); - $mutex->synchronized(static function (): void { - self::fail('Code should not be executed'); + $this->mutex->synchronized(static function (): void { + self::fail('Code execution is not expected'); }); } /** - * @return iterable> + * Tests evalScript() fails. */ - public static function provideTimingOutCases(): iterable + public function testEvalScriptFails(): void { - yield [1, 1.2 - 1, 1.201]; - yield [2, 1.2 - 1, 1.401]; + $this->expectException(LockReleaseException::class); + + $this->mutex->synchronized(function (): void { + $this->closeMajorityConnections(); + }); } /** - * Tests synchronized() works if the majority of keys was acquired. - * - * @param int $count The total count of servers - * @param int $available The count of available servers + * @param \Redis::SERIALIZER_* $serializer + * @param \Redis::COMPRESSION_* $compressor * - * @dataProvider provideMajorityCases + * @dataProvider provideSerializersAndCompressorsCases */ - #[DataProvider('provideMajorityCases')] - public function testAcquireWithMajority(int $count, int $available): void + #[DataProvider('provideSerializersAndCompressorsCases')] + public function testSerializersAndCompressors(int $serializer, int $compressor): void { - $mutex = $this->createRedisMutexMock($count); - $mutex->expects(self::exactly($count)) - ->method('evalScript') - ->willReturn(true); - - $i = 0; - $mutex->expects(self::exactly($count)) - ->method('add') - ->willReturnCallback( - static function () use (&$i, $available): bool { - ++$i; - - return $i <= $available; - } - ); + foreach ($this->connections as $connection) { + $connection->setOption(\Redis::OPT_SERIALIZER, $serializer); + $connection->setOption(\Redis::OPT_COMPRESSION, $compressor); + } - $mutex->synchronized(static function (): void {}); + self::assertSame('test', $this->mutex->synchronized(static function (): string { + return 'test'; + })); } - /** - * Tests releasing fails because too few servers are available. - * - * @param int $count The total count of servers - * @param int $available The count of available servers - * - * @dataProvider provideMinorityCases - */ - #[DataProvider('provideMinorityCases')] - public function testTooFewServersToRelease(int $count, int $available): void + public function testResistantToPartialClusterFailuresForAcquiringLock(): void { - $mutex = $this->createRedisMutexMock($count); - $mutex->expects(self::exactly($count)) - ->method('add') - ->willReturn(true); - - $i = 0; - $mutex->expects(self::exactly($count)) - ->method('evalScript') - ->willReturnCallback( - static function () use (&$i, $available): bool { - if ($i < $available) { - ++$i; - - return true; - } - - throw new LockReleaseException(); - } - ); + $this->closeMinorityConnections(); - $this->expectException(LockReleaseException::class); - - $mutex->synchronized(static function (): void {}); + self::assertSame('test', $this->mutex->synchronized(static function (): string { + return 'test'; + })); } - /** - * Tests releasing too few keys. - * - * @param int $count The total count of servers - * @param int $available The count of available servers - * - * @dataProvider provideMinorityCases - */ - #[DataProvider('provideMinorityCases')] - public function testReleaseTooFewKeys(int $count, int $available): void + public function testResistantToPartialClusterFailuresForReleasingLock(): void { - $mutex = $this->createRedisMutexMock($count); - $mutex->expects(self::exactly($count)) - ->method('add') - ->willReturn(true); - - $i = 0; - $mutex->expects(self::exactly($count)) - ->method('evalScript') - ->willReturnCallback( - static function () use (&$i, $available): bool { - ++$i; - - return $i <= $available; - } - ); - - $this->expectException(LockReleaseException::class); + self::assertNull($this->mutex->synchronized(function () { // @phpstan-ignore staticMethod.alreadyNarrowedType + $this->closeMinorityConnections(); - $mutex->synchronized(static function (): void {}); + return null; + })); } /** - * Provides test cases with too few. - * * @return iterable> */ - public static function provideMinorityCases(): iterable + public static function provideSerializersAndCompressorsCases(): iterable { - yield [1, 0]; - yield [2, 0]; - yield [2, 1]; - yield [3, 0]; - yield [3, 1]; - yield [4, 0]; - yield [4, 1]; - yield [4, 2]; - } + if (!class_exists(\Redis::class)) { + return; + } - /** - * Provides test cases with enough. - * - * @return iterable> - */ - public static function provideMajorityCases(): iterable - { - yield [1, 1]; - yield [2, 2]; - yield [3, 2]; - yield [3, 3]; - yield [4, 3]; + yield [\Redis::SERIALIZER_NONE, \Redis::COMPRESSION_NONE]; + yield [\Redis::SERIALIZER_PHP, \Redis::COMPRESSION_NONE]; + + if (defined('Redis::SERIALIZER_IGBINARY') && extension_loaded('igbinary')) { + yield [ + constant('Redis::SERIALIZER_IGBINARY'), + \Redis::COMPRESSION_NONE, + ]; + } + + if (defined('Redis::COMPRESSION_LZF') && extension_loaded('lzf')) { + yield [ + \Redis::SERIALIZER_NONE, + constant('Redis::COMPRESSION_LZF'), + ]; + yield [ + \Redis::SERIALIZER_PHP, + constant('Redis::COMPRESSION_LZF'), + ]; + + if (defined('Redis::SERIALIZER_IGBINARY') && extension_loaded('igbinary')) { + yield [ + constant('Redis::SERIALIZER_IGBINARY'), + constant('Redis::COMPRESSION_LZF'), + ]; + } + } } } diff --git a/tests/Mutex/PredisMutexTest.php b/tests/Mutex/RedisMutexWithPredisTest.php similarity index 89% rename from tests/Mutex/PredisMutexTest.php rename to tests/Mutex/RedisMutexWithPredisTest.php index d1635325..aee09421 100644 --- a/tests/Mutex/PredisMutexTest.php +++ b/tests/Mutex/RedisMutexWithPredisTest.php @@ -6,16 +6,16 @@ use Malkusch\Lock\Exception\LockAcquireException; use Malkusch\Lock\Exception\LockReleaseException; -use Malkusch\Lock\Mutex\PredisMutex; +use Malkusch\Lock\Mutex\RedisMutex; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Constraint\IsType; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; -use Predis\ClientInterface; +use Predis\ClientInterface as PredisClientInterface; use Predis\PredisException; use Psr\Log\LoggerInterface; -interface ClientInterfaceWithSetAndEvalMethods extends ClientInterface +interface PredisClientInterfaceWithSetAndEvalMethods extends PredisClientInterface { /** * @return mixed @@ -32,12 +32,12 @@ public function set(); * @group redis */ #[Group('redis')] -class PredisMutexTest extends TestCase +class RedisMutexWithPredisTest extends TestCase { - /** @var ClientInterface&MockObject */ + /** @var PredisClientInterface&MockObject */ private $client; - /** @var PredisMutex */ + /** @var RedisMutex */ private $mutex; /** @var LoggerInterface&MockObject */ @@ -48,9 +48,9 @@ protected function setUp(): void { parent::setUp(); - $this->client = $this->createMock(ClientInterfaceWithSetAndEvalMethods::class); + $this->client = $this->createMock(PredisClientInterfaceWithSetAndEvalMethods::class); - $this->mutex = new PredisMutex([$this->client], 'test', 2.5); + $this->mutex = new RedisMutex([$this->client], 'test', 2.5); $this->logger = $this->createMock(LoggerInterface::class); $this->mutex->setLogger($this->logger);