├── .gitignore ├── src └── RedisLock │ ├── Exception │ ├── LockException.php │ ├── LostLockException.php │ ├── InvalidArgumentException.php │ └── LockHasAcquiredAlreadyException.php │ ├── LockInterface.php │ └── RedisLock.php ├── test ├── CompatibilityTest.php ├── VersionTest.php ├── Unit │ └── RedisLockTest.php └── Integration │ ├── RedisLockParallelTest.php │ └── RedisLockTest.php ├── .github └── FUNDING.yml ├── .travis.yml ├── composer.json ├── LICENSE ├── phpunit.xml ├── example.php └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /vendor/ 3 | composer.lock 4 | -------------------------------------------------------------------------------- /src/RedisLock/Exception/LockException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace RedisLock\Exception; 12 | 13 | class LockException extends \Exception { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/RedisLock/Exception/LostLockException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace RedisLock\Exception; 12 | 13 | class LostLockException extends LockException { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/RedisLock/Exception/InvalidArgumentException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace RedisLock\Exception; 12 | 13 | class InvalidArgumentException extends LockException { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /src/RedisLock/Exception/LockHasAcquiredAlreadyException.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace RedisLock\Exception; 12 | 13 | class LockHasAcquiredAlreadyException extends LockException { 14 | 15 | } 16 | -------------------------------------------------------------------------------- /test/CompatibilityTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace Test; 12 | 13 | class CompatibilityTest extends \PHPUnit_Framework_TestCase { 14 | 15 | public function test_compatibility() { 16 | if (!function_exists('posix_getpid')) { 17 | $this->markTestSkipped(); 18 | return; 19 | } 20 | $this->assertSame(posix_getpid(), getmypid(), 'posix_getpid() != getmypid()'); 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: cheprasov # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | php: 3 | - '5.5' 4 | - '5.6' 5 | - '7.0' 6 | - '7.1' 7 | - hhvm 8 | 9 | matrix: 10 | allow_failures: 11 | - php: hhvm 12 | 13 | before_install: 14 | - PHP_INI=~/.phpenv/versions/$(phpenv version-name)/etc/php.ini; 15 | - | 16 | if [[ $TRAVIS_PHP_VERSION =~ 5.[56] ]]; then 17 | echo "extension = memcached.so" >> $PHP_INI; 18 | phpenv config-rm xdebug.ini; 19 | fi; 20 | - | 21 | if [[ $TRAVIS_PHP_VERSION =~ 7.[10] ]]; then 22 | apt-get install -y php-memcached; 23 | echo "extension = memcached.so" >> $PHP_INI; 24 | fi; 25 | - | 26 | if [[ $TRAVIS_PHP_VERSION =~ 7.[0] ]]; then 27 | phpenv config-rm xdebug.ini; 28 | fi; 29 | 30 | install: 31 | - composer install 32 | 33 | services: 34 | - memcached 35 | - redis 36 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cheprasov/php-redis-lock", 3 | "version": "1.0.3", 4 | "description": "RedisLock for PHP is a synchronization mechanism for enforcing limits on access to a resource in an environment where there are many threads of execution. A lock is designed to enforce a mutual exclusion concurrency control policy.", 5 | "homepage": "http://github.com/cheprasov/php-redis-lock", 6 | "minimum-stability": "stable", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Alexander Cheprasov", 11 | "email": "acheprasov84@gmail.com" 12 | } 13 | ], 14 | "autoload": { 15 | "psr-0": {"": "src/"} 16 | }, 17 | "require": { 18 | "php": ">=5.5", 19 | "cheprasov/php-redis-client" : "^1.7.2" 20 | }, 21 | "require-dev": { 22 | "phpunit/phpunit": "4.8.*", 23 | "cheprasov/php-parallel": "~1.2" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /test/VersionTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace Test; 12 | 13 | use RedisLock\RedisLock; 14 | 15 | class VersionTest extends \PHPUnit_Framework_TestCase { 16 | 17 | public function test_version() { 18 | chdir(__DIR__.'/../'); 19 | $composer = json_decode(file_get_contents('./composer.json'), true); 20 | 21 | $this->assertSame(true, isset($composer['version'])); 22 | $this->assertSame( 23 | RedisLock::VERSION, 24 | $composer['version'], 25 | 'Please, change version in composer.json' 26 | ); 27 | 28 | $readme = file('./README.md'); 29 | $this->assertSame( 30 | true, 31 | strpos($readme[4], 'RedisLock v'.$composer['version']) > 0, 32 | 'Please, change version in README.md' 33 | ); 34 | 35 | } 36 | 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Alexander Cheprasov 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | ./test/ 31 | 32 | 33 | 34 | 35 | -------------------------------------------------------------------------------- /src/RedisLock/LockInterface.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace RedisLock; 12 | 13 | interface LockInterface { 14 | 15 | /** 16 | * Acquire the lock 17 | * @param int|float $lockTime in Seconds 18 | * @param int|float $waitTime in Seconds 19 | * @param int $sleep in Seconds 20 | * @return bool 21 | */ 22 | public function acquire($lockTime, $waitTime = 0, $sleep = null); 23 | 24 | /** 25 | * Release the lock 26 | * @return bool 27 | */ 28 | public function release(); 29 | 30 | /** 31 | * Set a new time for acquired lock 32 | * @param int|float $lockTime 33 | * @return bool 34 | */ 35 | public function update($lockTime); 36 | 37 | /** 38 | * Check this lock for acquired 39 | * @return bool 40 | */ 41 | public function isAcquired(); 42 | 43 | /** 44 | * Check this lock for acquired and not expired, and active yet 45 | * @return bool 46 | */ 47 | public function isLocked(); 48 | 49 | /** 50 | * Does lock exists or acquired anywhere else? 51 | * @return bool 52 | */ 53 | public function isExists(); 54 | 55 | } 56 | -------------------------------------------------------------------------------- /example.php: -------------------------------------------------------------------------------- 1 | 'tcp://127.0.0.1:6379' 11 | ]); 12 | 13 | // ... 14 | 15 | /** 16 | * Safe update json in Redis storage 17 | * @param Redis $Redis 18 | * @param string $key 19 | * @param array $array 20 | * @throws Exception 21 | */ 22 | function updateJsonInRedis(RedisClient $Redis, $key, array $array) { 23 | // Create new Lock instance 24 | $Lock = new RedisLock($Redis, 'Lock_'.$key, RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS); 25 | 26 | // Acquire lock for 2 sec. 27 | // If lock has acquired in another thread then we will wait 3 second, 28 | // until another thread release the lock. Otherwise it throws a exception. 29 | if (!$Lock->acquire(2, 3)) { 30 | throw new Exception('Can\'t get a Lock'); 31 | } 32 | 33 | // Get value from storage 34 | $json = $Redis->get($key); 35 | if (!$json) { 36 | $jsonArray = []; 37 | } else { 38 | $jsonArray = json_decode($json, true); 39 | } 40 | 41 | // Some operations with json 42 | $jsonArray = array_merge($jsonArray, $array); 43 | 44 | $json = json_encode($jsonArray); 45 | // Update key in storage 46 | $Redis->set($key, $json); 47 | 48 | // Release the lock 49 | // After $lock->release() another waiting thread (Lock) will be able to update json in storage 50 | $Lock->release(); 51 | } 52 | 53 | updateJsonInRedis($Redis, 'json-key', ['for' => 1, 'bar' => 2]); 54 | updateJsonInRedis($Redis, 'json-key', ['for' => 42, 'var' => 2016]); 55 | -------------------------------------------------------------------------------- /test/Unit/RedisLockTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace Test\Unit; 12 | 13 | use RedisClient\RedisClient; 14 | use RedisLock\RedisLock; 15 | use RedisClient\ClientFactory; 16 | 17 | class RedisLockTest extends \PHPUnit_Framework_TestCase { 18 | 19 | const TEST_KEY = 'testKey'; 20 | 21 | const TEST_TOKEN = 'testToken'; 22 | 23 | /** 24 | * @return RedisClient 25 | */ 26 | protected function getRedis() { 27 | return ClientFactory::create(); 28 | } 29 | 30 | protected function getRedisLockMock() { 31 | $RedisLockMock = $this->getMockBuilder(RedisLock::class) 32 | ->setMethods([ 33 | 'createToken', 34 | 'isFlagExist', 35 | ]) 36 | ->setConstructorArgs([ 37 | $Memcached = $this->getRedis(), 38 | static::TEST_KEY 39 | ]) 40 | ->getMock(); 41 | 42 | return $RedisLockMock; 43 | } 44 | 45 | /** 46 | * @see RedisLock::createToken 47 | */ 48 | public function testMethod_createToken() { 49 | $key = static::TEST_KEY; 50 | 51 | $Method = new \ReflectionMethod('\RedisLock\RedisLock', 'createToken'); 52 | $Method->setAccessible(true); 53 | 54 | $result = $Method->invoke(new RedisLock($this->getRedis(), $key)); 55 | $this->assertTrue(is_string($result)); 56 | $this->assertEquals(1, preg_match('/^(\d+):(0\.\d+ \d+):(\d+)$/', $result, $matches)); 57 | $this->assertEquals(posix_getpid(), (int) $matches[1]); 58 | } 59 | 60 | /** 61 | * @see RedisLock::isFlagExist 62 | */ 63 | public function testMethod_isFlagExist() { 64 | $key = static::TEST_KEY; 65 | $Method = new \ReflectionMethod(RedisLock::class, 'isFlagExist'); 66 | $Method->setAccessible(true); 67 | 68 | $RedisLock = new RedisLock($this->getRedis(), $key); 69 | $this->assertSame(false, $Method->invoke( 70 | $RedisLock, 71 | RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS 72 | )); 73 | 74 | $RedisLock = new RedisLock( 75 | $this->getRedis(), $key, 76 | RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS 77 | ); 78 | $this->assertSame(true, $Method->invoke( 79 | $RedisLock, 80 | RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS 81 | )); 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /test/Integration/RedisLockParallelTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace Test\Integration; 12 | 13 | use RedisClient\ClientFactory; 14 | use RedisLock\RedisLock; 15 | use Parallel\Parallel; 16 | use Parallel\Storage\MemcachedStorage; 17 | 18 | class RedisLockParallelTest extends \PHPUnit_Framework_TestCase { 19 | 20 | /** 21 | * @var \Redis 22 | */ 23 | protected static $Redis; 24 | 25 | protected function getRedis() { 26 | return ClientFactory::create([ 27 | 'server' => REDIS_TEST_SERVER, 28 | 'version' => '3.2.8', 29 | ]); 30 | } 31 | 32 | public function test_parallel() { 33 | $Redis = $this->getRedis(); 34 | $Redis->flushall(); 35 | $this->assertSame(true, $Redis->set('testcount', '1000000')); 36 | unset($Redis); 37 | 38 | $Storage = new MemcachedStorage( 39 | ['servers'=>[explode(':', MEMCACHED_TEST_SERVER)]] 40 | ); 41 | $Parallel = new Parallel($Storage); 42 | 43 | $start = microtime(true) + 2; 44 | 45 | // 1st operation 46 | $Parallel->run('foo', function() use ($start) { 47 | $RedisLock = new RedisLock($Redis = $this->getRedis(), 'lock_test'); 48 | while (microtime(true) < $start) { 49 | // wait for start 50 | } 51 | $c = 0; 52 | for ($i = 1; $i <= 10000; ++$i) { 53 | if ($RedisLock->acquire(2, 3)) { 54 | $count = (int) $Redis->get('testcount'); 55 | ++$count; 56 | $Redis->set('testcount', $count); 57 | $RedisLock->release(); 58 | ++$c; 59 | } 60 | } 61 | return $c; 62 | }); 63 | 64 | // 2st operation 65 | $Parallel->run('bar', function() use ($start) { 66 | $RedisLock = new RedisLock($Redis = $this->getRedis(), 'lock_test'); 67 | while (microtime(true) < $start) { 68 | // wait for start 69 | } 70 | $c = 0; 71 | for ($i = 1; $i <= 10000; ++$i) { 72 | if ($RedisLock->acquire(2, 3)) { 73 | $count = (int) $Redis->get('testcount'); 74 | ++$count; 75 | $Redis->set('testcount', $count); 76 | $RedisLock->release(); 77 | ++$c; 78 | } 79 | } 80 | return $c; 81 | }); 82 | 83 | $RedisLock = new RedisLock($Redis = $this->getRedis(), 'lock_test'); 84 | while (microtime(true) < $start) { 85 | // wait for start 86 | } 87 | $c = 0; 88 | for ($i = 1; $i <= 10000; ++$i) { 89 | if ($RedisLock->acquire(2, 3)) { 90 | $count = (int) $Redis->get('testcount'); 91 | ++$count; 92 | $Redis->set('testcount', $count); 93 | $RedisLock->release(); 94 | ++$c; 95 | } 96 | } 97 | 98 | $result = $Parallel->wait(['foo', 'bar']); 99 | 100 | $this->assertSame(10000, (int) $result['foo']); 101 | $this->assertSame(10000, (int) $result['bar']); 102 | $this->assertSame(10000, $c); 103 | $this->assertSame(1030000, (int) $Redis->get('testcount')); 104 | 105 | $Redis->flushall(); 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![MIT license](http://img.shields.io/badge/license-MIT-brightgreen.svg)](http://opensource.org/licenses/MIT) 2 | [![Latest Stable Version](https://poser.pugx.org/cheprasov/php-redis-lock/v/stable)](https://packagist.org/packages/cheprasov/php-redis-lock) 3 | [![Total Downloads](https://poser.pugx.org/cheprasov/php-redis-lock/downloads)](https://packagist.org/packages/cheprasov/php-redis-lock) 4 | 5 | # RedisLock v1.0.3 for PHP >= 5.5 6 | 7 | ## About 8 | RedisLock for PHP is a synchronization mechanism for enforcing limits on access to a resource in an environment where there are many threads of execution. A lock is designed to enforce a mutual exclusion concurrency control policy. Based on [redis](http://redis.io/). 9 | 10 | 11 | ## Usage 12 | 13 | ### Create a new instance of RedisLock 14 | 15 | ```php 16 | 'tcp://127.0.0.1:6379' 26 | ]); 27 | 28 | $Lock = new RedisLock( 29 | $Redis, // Instance of RedisClient, 30 | 'key', // Key in storage, 31 | ); 32 | ``` 33 | 34 | ### Usage for lock a process 35 | 36 | ```php 37 | 'tcp://127.0.0.1:6379' 47 | ]); 48 | 49 | // ... 50 | 51 | /** 52 | * Safe update json in Redis storage 53 | * @param Redis $Redis 54 | * @param string $key 55 | * @param array $array 56 | * @throws Exception 57 | */ 58 | function updateJsonInRedis(RedisClient $Redis, $key, array $array) { 59 | // Create new Lock instance 60 | $Lock = new RedisLock($Redis, 'Lock_'.$key, RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS); 61 | 62 | // Acquire lock for 2 sec. 63 | // If lock has acquired in another thread then we will wait 3 second, 64 | // until another thread release the lock. Otherwise it throws a exception. 65 | if (!$Lock->acquire(2, 3)) { 66 | throw new Exception('Can\'t get a Lock'); 67 | } 68 | 69 | // Get value from storage 70 | $json = $Redis->get($key); 71 | if (!$json) { 72 | $jsonArray = []; 73 | } else { 74 | $jsonArray = json_decode($json, true); 75 | } 76 | 77 | // Some operations with json 78 | $jsonArray = array_merge($jsonArray, $array); 79 | 80 | $json = json_encode($jsonArray); 81 | // Update key in storage 82 | $Redis->set($key, $json); 83 | 84 | // Release the lock 85 | // After $lock->release() another waiting thread (Lock) will be able to update json in storage 86 | $Lock->release(); 87 | } 88 | 89 | updateJsonInRedis($Redis, 'json-key', ['for' => 1, 'bar' => 2]); 90 | updateJsonInRedis($Redis, 'json-key', ['for' => 42, 'var' => 2016]); 91 | 92 | ``` 93 | 94 | ## Methods 95 | 96 | #### RedisLock :: __construct ( `RedisClient` **$Redis** , `string` **$key** [, `int` **$flags** = 0 ] ) 97 | --- 98 | Create a new instance of RedisLock. 99 | 100 | ##### Method Pameters 101 | 102 | 1. RedisClient **$Redis** - Instanse of [RedisClient](https://github.com/cheprasov/php-redis-client) 103 | 2. string **$key** - name of key in Redis storage. Only locks with the same name will compete with each other for lock. 104 | 3. int **$flags**, default = 0 105 | * `RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS` - use this flag, if you don't want catch exceptions by yourself. Do not use this flag, if you want have a full control on situation with locks. Default behavior without this flag - all Exceptions will be thrown. 106 | 107 | ##### Example 108 | 109 | ```php 110 | $Lock = new RedisLock($Redis, 'lockName'); 111 | // or 112 | $Lock = new RedisLock($Redis, 'lockName', RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS); 113 | 114 | ``` 115 | 116 | #### `bool` RedisLock :: acquire ( `int|float` **$lockTime** , [ `float` **$waitTime** = 0 [, `float` **$sleep** = 0.005 ] ] ) 117 | --- 118 | Try to acquire lock for `$lockTime` seconds. 119 | If lock has acquired in another thread then we will wait `$waitTime` seconds, until another thread release the lock. 120 | Otherwise method throws a exception (if `FLAG_DO_NOT_THROW_EXCEPTIONS` is not set) or result. 121 | Returns `true` on success or `false` on failure. 122 | 123 | ##### Method Pameters 124 | 125 | 1. int|float **$lockTime** - The time for lock in seconds, the value must be `>= 0.01`. 126 | 2. float **$waitTime**, default = 0 - The time for waiting lock in seconds. Use `0` if you don't wait until lock release. 127 | 3. float **$sleep**, default = 0.005 - The wait time between iterations to check the availability of the lock. 128 | 129 | ##### Example 130 | 131 | ```php 132 | $Lock = new RedisLock($Redis, 'lockName'); 133 | $Lock->acquire(3, 4); 134 | // ... do something 135 | $Lock->release(); 136 | ``` 137 | 138 | #### `bool` RedisLock :: update ( `int|float` **$lockTime** ) 139 | --- 140 | Set a new time for lock if it is acquired already. Returns `true` on success or `false` on failure. Method can throw Exceptions. 141 | 142 | ##### Method Pameters 143 | 1. int|float **$lockTime** - Please, see description for method `RedisLock :: acquire` 144 | 145 | ##### Example 146 | 147 | ```php 148 | $Lock = new RedisLock($Redis, 'lockName'); 149 | $Lock->acquire(3, 4); 150 | // ... do something 151 | $Lock->update(3); 152 | // ... do something 153 | $Lock->release(); 154 | ``` 155 | 156 | #### `bool` RedisLock :: isAcquired ( ) 157 | --- 158 | Check this lock for acquired. Returns `true` on success or `false` on failure. 159 | 160 | #### `bool` RedisLock :: isLocked ( ) 161 | --- 162 | Check this lock for acquired and not expired, and active yet. Returns `true` on success or `false` on failure. Method can throw Exceptions. 163 | 164 | #### `bool` RedisLock :: isExists () 165 | --- 166 | Does lock exists or acquired anywhere? Returns `true` if lock is exists or `false` if is not. 167 | 168 | ## Installation 169 | 170 | ### Composer 171 | 172 | Download composer: 173 | 174 | wget -nc http://getcomposer.org/composer.phar 175 | 176 | and add dependency to your project: 177 | 178 | php composer.phar require cheprasov/php-redis-lock 179 | 180 | ## Running tests 181 | 182 | To run tests type in console: 183 | 184 | ./vendor/bin/phpunit 185 | 186 | ## Something doesn't work 187 | 188 | Feel free to fork project, fix bugs and finally request for pull 189 | -------------------------------------------------------------------------------- /src/RedisLock/RedisLock.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace RedisLock; 12 | 13 | use RedisClient\Exception\ErrorResponseException; 14 | use RedisClient\RedisClient; 15 | use RedisLock\Exception\InvalidArgumentException; 16 | use RedisLock\Exception\LockException; 17 | use RedisLock\Exception\LockHasAcquiredAlreadyException; 18 | use RedisLock\Exception\LostLockException; 19 | 20 | class RedisLock implements LockInterface { 21 | 22 | const VERSION = '1.0.3'; 23 | 24 | /** 25 | * @deprecated 26 | * @see FLAG_DO_NOT_THROW_EXCEPTIONS 27 | * Catch Lock exceptions and return false or null as result 28 | */ 29 | const FLAG_CATCH_EXCEPTIONS = 1; 30 | 31 | /** 32 | * Do not throw exception, return false or null as result 33 | */ 34 | const FLAG_DO_NOT_THROW_EXCEPTIONS = 1; 35 | 36 | /** 37 | * Sleep time between wait iterations, in seconds 38 | */ 39 | const LOCK_DEFAULT_WAIT_SLEEP = 0.005; 40 | 41 | /** 42 | * Min lock time in seconds 43 | */ 44 | const LOCK_MIN_TIME = 0.001; 45 | 46 | const LUA_SCRIPT_RELEASE_LOCK_SHA1 = '9bdce90060b1eb1923ba581ffba7051865f063d7'; 47 | const LUA_SCRIPT_RELEASE_LOCK = ' 48 | if (ARGV[1] == redis.call("GET", KEYS[1])) then 49 | return redis.call("DEL", KEYS[1]); 50 | end; 51 | return 0; 52 | '; 53 | 54 | const LUA_SCRIPT_UPDATE_LOCK_SHA1 = 'b414769872ec8518662b9f29e83fc691b0349f45'; 55 | const LUA_SCRIPT_UPDATE_LOCK = ' 56 | if (ARGV[1] == redis.call("GET", KEYS[1])) then 57 | return redis.call("PEXPIRE", KEYS[1], ARGV[2]); 58 | end; 59 | return 0; 60 | '; 61 | 62 | /** 63 | * @var RedisClient 64 | */ 65 | protected $Redis; 66 | 67 | /** 68 | * @var string 69 | */ 70 | protected $key; 71 | 72 | /** 73 | * @var string 74 | */ 75 | protected $token; 76 | 77 | /** 78 | * Flags 79 | * @var int 80 | */ 81 | protected $flags = 0; 82 | 83 | /** 84 | * @var bool 85 | */ 86 | protected $isAcquired = false; 87 | 88 | /** 89 | * @var bool 90 | */ 91 | protected $catchExceptions = false; 92 | 93 | /** 94 | * @param RedisClient $Redis 95 | * @param string $key 96 | * @param int $flags 97 | * @throws InvalidArgumentException 98 | */ 99 | public function __construct(RedisClient $Redis, $key, $flags = 0) { 100 | if (!isset($key)) { 101 | throw new InvalidArgumentException('Invalid key for Lock'); 102 | } 103 | $this->Redis = $Redis; 104 | $this->key = (string) $key; 105 | $this->flags = (int) $flags; 106 | 107 | $this->token = $this->createToken(); 108 | $this->catchExceptions = $this->isFlagExist(self::FLAG_DO_NOT_THROW_EXCEPTIONS); 109 | } 110 | 111 | /** 112 | * @param int $flag 113 | * @return bool 114 | */ 115 | protected function isFlagExist($flag) { 116 | return (bool) ($this->flags & $flag); 117 | } 118 | 119 | /** 120 | * 121 | */ 122 | public function __destruct() { 123 | if ($this->isAcquired) { 124 | $this->release(); 125 | } 126 | } 127 | 128 | /** 129 | * @return string 130 | */ 131 | protected function createToken() { 132 | return getmypid() .':'. microtime() .':'. mt_rand(1, 9999); 133 | } 134 | 135 | /** 136 | * @param string $script 137 | * @param string $sha1 138 | * @param string[]|null $keys 139 | * @param string[]|null $args 140 | * @return mixed 141 | * @throws ErrorResponseException 142 | */ 143 | protected function execLua($script, $sha1, $keys = null, $args = null) { 144 | try { 145 | return $this->Redis->evalsha($sha1, $keys, $args); 146 | } catch (ErrorResponseException $Ex) { 147 | if (0 === strpos($Ex->getMessage(), 'NOSCRIPT')) { 148 | return $this->Redis->eval($script, $keys, $args); 149 | } 150 | throw $Ex; 151 | } 152 | } 153 | 154 | /** 155 | * @inheritdoc 156 | * @throws InvalidArgumentException 157 | * @throws LockHasAcquiredAlreadyException 158 | */ 159 | public function acquire($lockTime, $waitTime = 0, $sleep = null) { 160 | if ($lockTime < self::LOCK_MIN_TIME) { 161 | if ($this->catchExceptions) { 162 | return false; 163 | } 164 | throw new InvalidArgumentException('LockTime must be not less than '. self::LOCK_MIN_TIME. ' sec.'); 165 | } 166 | if ($this->isAcquired) { 167 | if ($this->catchExceptions) { 168 | return false; 169 | } 170 | throw new LockHasAcquiredAlreadyException('Lock with key "'. $this->key .'" has acquired already'); 171 | } 172 | 173 | $time = microtime(true); 174 | $exitTime = $waitTime + $time; 175 | $sleep = ($sleep ?: self::LOCK_DEFAULT_WAIT_SLEEP) * 1000000; 176 | 177 | do { 178 | if ($this->Redis->set($this->key, $this->token, null, $lockTime * 1000, 'NX')) { 179 | $this->isAcquired = true; 180 | return true; 181 | } 182 | if ($waitTime) { 183 | usleep($sleep); 184 | } 185 | } while ($waitTime && microtime(true) < $exitTime); 186 | 187 | $this->isAcquired = false; 188 | return false; 189 | } 190 | 191 | /** 192 | * @inheritdoc 193 | * @throws LockException 194 | */ 195 | public function release() { 196 | if (!$this->isAcquired) { 197 | if ($this->catchExceptions) { 198 | return false; 199 | } 200 | throw new LockException('Lock "'. $this->key .'" is not acquired'); 201 | } 202 | 203 | $result = $this->execLua( 204 | self::LUA_SCRIPT_RELEASE_LOCK, 205 | self::LUA_SCRIPT_RELEASE_LOCK_SHA1, 206 | [$this->key], 207 | [$this->token] 208 | ); 209 | 210 | $this->isAcquired = false; 211 | 212 | if ($result) { 213 | return true; 214 | } 215 | 216 | if ($this->catchExceptions) { 217 | return false; 218 | } 219 | throw new LostLockException('Lock "'. $this->key .'" has lost.'); 220 | } 221 | 222 | /** 223 | * @inheritdoc 224 | * @throws InvalidArgumentException 225 | * @throws LockException 226 | */ 227 | public function update($lockTime) { 228 | if ($lockTime < self::LOCK_MIN_TIME) { 229 | if ($this->catchExceptions) { 230 | return false; 231 | } 232 | throw new InvalidArgumentException('LockTime must be not less than '. self::LOCK_MIN_TIME. ' sec.'); 233 | } 234 | if (!$this->isAcquired) { 235 | if ($this->catchExceptions) { 236 | return false; 237 | } 238 | throw new LockException('Lock "'. $this->key .'" is not active'); 239 | } 240 | 241 | $result = $this->execLua( 242 | self::LUA_SCRIPT_UPDATE_LOCK, 243 | self::LUA_SCRIPT_UPDATE_LOCK_SHA1, 244 | [$this->key], 245 | [$this->token, $lockTime * 1000] 246 | ); 247 | 248 | if ($result) { 249 | return true; 250 | } 251 | 252 | if ($this->catchExceptions) { 253 | return false; 254 | } 255 | throw new LostLockException('Lost Lock "'. $this->key .'" on update.'); 256 | } 257 | 258 | /** 259 | * @inheritdoc 260 | */ 261 | public function isAcquired() { 262 | return $this->isAcquired; 263 | } 264 | 265 | /** 266 | * @inheritdoc 267 | * @throws LostLockException 268 | */ 269 | public function isLocked() { 270 | if (!$this->isAcquired) { 271 | return false; 272 | } 273 | 274 | $token = $this->Redis->get($this->key); 275 | if ($token && $token === $this->token) { 276 | return true; 277 | } 278 | 279 | $this->isAcquired = false; 280 | 281 | if ($this->catchExceptions) { 282 | return false; 283 | } 284 | throw new LostLockException('Lost Lock "'. $this->key .'"'); 285 | } 286 | 287 | /** 288 | * @inheritdoc 289 | */ 290 | public function isExists() { 291 | return $this->Redis->get($this->key) ? true : false; 292 | } 293 | 294 | } 295 | -------------------------------------------------------------------------------- /test/Integration/RedisLockTest.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | namespace Test\Integration; 12 | 13 | use RedisClient\ClientFactory; 14 | use RedisClient\RedisClient; 15 | use RedisLock\Exception\InvalidArgumentException; 16 | use RedisLock\Exception\LockException; 17 | use RedisLock\Exception\LockHasAcquiredAlreadyException; 18 | use RedisLock\Exception\LostLockException; 19 | use RedisLock\RedisLock; 20 | 21 | class RedisLockTest extends \PHPUnit_Framework_TestCase { 22 | 23 | const TEST_KEY = 'redisLockTestKey'; 24 | 25 | const LOCK_MIN_TIME = 0.05; 26 | 27 | /** 28 | * @var RedisClient 29 | */ 30 | protected static $Redis; 31 | 32 | public static function setUpBeforeClass() { 33 | static::$Redis = ClientFactory::create([ 34 | 'server' => REDIS_TEST_SERVER, 35 | 'version' => '3.2.8', 36 | ]); 37 | } 38 | 39 | public function testRedis() { 40 | $this->assertInstanceOf(RedisClient::class, static::$Redis); 41 | } 42 | 43 | public function setUp() { 44 | $this->assertSame(true, static::$Redis->flushall()); 45 | } 46 | 47 | public function test_RedisLock() { 48 | $key = static::TEST_KEY; 49 | $RedisLock = new RedisLock(static::$Redis, $key); 50 | 51 | try { 52 | $RedisLock->acquire(RedisLock::LOCK_MIN_TIME * 0.9); 53 | $this->assertFalse("Expect Exception " . InvalidArgumentException::class); 54 | } catch (\Exception $Exception) { 55 | $this->assertInstanceOf(InvalidArgumentException::class, $Exception); 56 | } 57 | 58 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * 2)); 59 | 60 | try { 61 | $RedisLock->acquire(self::LOCK_MIN_TIME * 2); 62 | $this->assertFalse("Expect Exception " . LockHasAcquiredAlreadyException::class); 63 | } catch (\Exception $Exception) { 64 | $this->assertInstanceOf(LockHasAcquiredAlreadyException::class, $Exception); 65 | } 66 | 67 | try { 68 | $RedisLock->acquire(RedisLock::LOCK_MIN_TIME * 0.9); 69 | $this->assertFalse("Expect Exception " . InvalidArgumentException::class); 70 | } catch (\Exception $Exception) { 71 | $this->assertInstanceOf(InvalidArgumentException::class, $Exception); 72 | } 73 | 74 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 4)); 75 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 3)); 76 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 77 | 78 | $this->assertTrue($RedisLock->isLocked()); 79 | $this->assertTrue($RedisLock->isExists()); 80 | 81 | $this->assertTrue($RedisLock->release()); 82 | 83 | $this->assertFalse($RedisLock->isLocked()); 84 | $this->assertFalse($RedisLock->isExists()); 85 | 86 | try { 87 | $RedisLock->release(); 88 | $this->assertFalse("Expect " . LockException::class); 89 | } catch (\Exception $Exception) { 90 | $this->assertInstanceOf(LockException::class, $Exception); 91 | } 92 | 93 | try { 94 | $RedisLock->update(self::LOCK_MIN_TIME * 2); 95 | $this->assertFalse("Expect " . LockException::class); 96 | } catch (\Exception $Exception) { 97 | $this->assertInstanceOf(LockException::class, $Exception); 98 | } 99 | 100 | $this->assertFalse($RedisLock->isLocked()); 101 | $this->assertFalse($RedisLock->isExists()); 102 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * 2)); 103 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 104 | $this->assertTrue($RedisLock->isLocked()); 105 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 106 | $this->assertTrue($RedisLock->isExists()); 107 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 108 | $this->assertTrue($RedisLock->isLocked()); 109 | $this->assertTrue($RedisLock->release()); 110 | $this->assertFalse($RedisLock->isLocked()); 111 | $this->assertFalse($RedisLock->isExists()); 112 | } 113 | 114 | public function test_RedisLock_WithoutExceptions() { 115 | $key = static::TEST_KEY; 116 | $RedisLock = new RedisLock(static::$Redis, $key, RedisLock::FLAG_DO_NOT_THROW_EXCEPTIONS); 117 | 118 | $this->assertFalse($RedisLock->acquire(RedisLock::LOCK_MIN_TIME * 0.9)); 119 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * 2)); 120 | $this->assertFalse($RedisLock->acquire(self::LOCK_MIN_TIME * 2)); 121 | $this->assertFalse($RedisLock->acquire(RedisLock::LOCK_MIN_TIME * 0.9)); 122 | 123 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 4)); 124 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 3)); 125 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 126 | 127 | $this->assertTrue($RedisLock->isLocked()); 128 | $this->assertTrue($RedisLock->isExists()); 129 | 130 | $this->assertTrue($RedisLock->release()); 131 | 132 | $this->assertFalse($RedisLock->isLocked()); 133 | $this->assertFalse($RedisLock->isExists()); 134 | 135 | $this->assertFalse($RedisLock->release()); 136 | 137 | $this->assertFalse($RedisLock->update(self::LOCK_MIN_TIME * 2)); 138 | 139 | $this->assertFalse($RedisLock->isLocked()); 140 | $this->assertFalse($RedisLock->isExists()); 141 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * 2)); 142 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 143 | $this->assertTrue($RedisLock->isLocked()); 144 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 145 | $this->assertTrue($RedisLock->isExists()); 146 | $this->assertTrue($RedisLock->update(self::LOCK_MIN_TIME * 2)); 147 | $this->assertTrue($RedisLock->isLocked()); 148 | $this->assertTrue($RedisLock->release()); 149 | $this->assertFalse($RedisLock->isLocked()); 150 | $this->assertFalse($RedisLock->isExists()); 151 | } 152 | 153 | public function test_RedisLock_LockTime() { 154 | $key = static::TEST_KEY; 155 | 156 | $RedisLock = new RedisLock(static::$Redis, $key); 157 | $RedisLock2 = new RedisLock(static::$Redis, $key); 158 | 159 | for ($i = 1; $i <= 5; $i++) { 160 | $microtime = microtime(true); 161 | 162 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * $i)); 163 | 164 | $this->assertTrue($RedisLock->isLocked()); 165 | $this->assertTrue($RedisLock->isExists()); 166 | 167 | $this->assertFalse($RedisLock2->isLocked()); 168 | $this->assertTrue($RedisLock2->isExists()); 169 | 170 | //$microtime = microtime(true); 171 | $this->assertTrue($RedisLock2->acquire(self::LOCK_MIN_TIME * $i, $i + 1)); 172 | $waitTime = microtime(true) - $microtime; 173 | 174 | $this->assertTrue($RedisLock2->update(1)); 175 | 176 | $this->assertGreaterThan(self::LOCK_MIN_TIME * $i - 1, $waitTime); 177 | $this->assertLessThanOrEqual(self::LOCK_MIN_TIME * $i + 1, $waitTime); 178 | 179 | try { 180 | $RedisLock->isLocked(); 181 | $this->assertFalse('Expect LostLockException'); 182 | } catch (\Exception $Ex) { 183 | $this->assertInstanceOf(LostLockException::class, $Ex); 184 | } 185 | 186 | $this->assertTrue($RedisLock->isExists()); 187 | 188 | $this->assertTrue($RedisLock2->isLocked()); 189 | $this->assertTrue($RedisLock2->isExists()); 190 | 191 | $this->assertTrue($RedisLock2->release()); 192 | } 193 | } 194 | 195 | public function test_RedisLock_WaitTime() { 196 | $key = static::TEST_KEY; 197 | $RedisLock = new RedisLock(static::$Redis, $key); 198 | $RedisLock2 = new RedisLock(static::$Redis, $key); 199 | 200 | for ($i = 1; $i <= 5; $i++) { 201 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * $i)); 202 | $this->assertFalse($RedisLock2->acquire( 203 | self::LOCK_MIN_TIME * $i, 204 | self::LOCK_MIN_TIME * ($i - 1)) 205 | ); 206 | $this->assertTrue($RedisLock->release()); 207 | } 208 | 209 | for ($i = 1; $i <= 5; $i++) { 210 | $this->assertTrue($RedisLock->acquire(self::LOCK_MIN_TIME * $i)); 211 | $this->assertTrue($RedisLock2->acquire( 212 | self::LOCK_MIN_TIME * $i, 213 | self::LOCK_MIN_TIME * ($i + 1) 214 | )); 215 | $this->assertTrue($RedisLock2->release()); 216 | try { 217 | $this->assertTrue($RedisLock->release()); 218 | $this->assertFalse('Expect LostLockException'); 219 | } catch (\Exception $Ex) { 220 | $this->assertInstanceOf(LostLockException::class, $Ex); 221 | } 222 | } 223 | } 224 | 225 | public function test_RedisLock_Exceptions() { 226 | $key = static::TEST_KEY; 227 | $RedisLock = new RedisLock(static::$Redis, $key); 228 | 229 | $this->assertSame(true, $RedisLock->acquire(2)); 230 | $this->assertSame(true, $RedisLock->isLocked()); 231 | 232 | static::$Redis->del($key); 233 | 234 | try { 235 | $RedisLock->release(); 236 | $this->assertFalse('Expect LostLockException'); 237 | } catch (\Exception $Ex) { 238 | $this->assertInstanceOf(LostLockException::class, $Ex); 239 | } 240 | 241 | $this->assertSame(false, $RedisLock->isLocked()); 242 | 243 | $this->assertSame(true, $RedisLock->acquire(2)); 244 | $this->assertSame(true, $RedisLock->isLocked()); 245 | 246 | static::$Redis->del($key); 247 | 248 | $this->assertSame(false, $RedisLock->isExists()); 249 | try { 250 | $RedisLock->isLocked(); 251 | $this->assertFalse('Expect LostLockException'); 252 | } catch (\Exception $Ex) { 253 | $this->assertInstanceOf(LostLockException::class, $Ex); 254 | } 255 | } 256 | 257 | } 258 | --------------------------------------------------------------------------------