diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c55784d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +composer.lock +/vendor/ diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..5753122 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,18 @@ +language: php + +sudo: false + +services: + - redis-server + +php: + - 5.5 + - 5.6 + - 7.0 + - hhvm + +before_script: + - composer install -n + +script: + - vendor/bin/phpunit tests diff --git a/README.md b/README.md new file mode 100644 index 0000000..44407ae --- /dev/null +++ b/README.md @@ -0,0 +1,72 @@ +# php-lock-redis + +[![Build Status](https://travis-ci.org/texthtml/php-lock-redis.svg?branch=master)](https://travis-ci.org/texthtml/php-lock-redis) +[![Latest Stable Version](https://poser.pugx.org/texthtml/php-lock-redis/v/stable.svg)](https://packagist.org/packages/texthtml/php-lock-redis) +[![License](https://poser.pugx.org/texthtml/php-lock-redis/license.svg)](http://www.gnu.org/licenses/agpl-3.0.html) +[![Total Downloads](https://poser.pugx.org/texthtml/php-lock-redis/downloads.svg)](https://packagist.org/packages/texthtml/php-lock-redis) +[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/texthtml/php-lock-redis/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/texthtml/php-lock-redis/?branch=master) + +[php-lock-redis](https://packagist.org/packages/texthtml/php-lock-redis) is an extension for [php-lock](https://packagist.org/packages/texthtml/php-lock) that makes locking on resources easy on distributed system using Redis. It can be used instead of file base locking to lock operations on a distributed system. + +## Installation + +With Composer : + +```bash +composer require texthtml/php-lock-redis +``` + +## Usage + +You can create an object that represent a lock on a resource. You can then try to acquire that lock by calling `$lock->acquire()`. If the lock fail it will throw an `Exception` (useful for CLI tools built with [Symfony Console Components documentation](http://symfony.com/doc/current/components/console/introduction.html)). If the lock is acquired the program can continue. + +### Locking a ressource + +```php +use TH\RedisLock\RedisSimpleLockFactory; + +$redisClient = new \Predis\Client; +$factory = new RedisSimpleLockFactory($redisClient); +$lock = $factory->create('lock identifier'); + +$lock->acquire(); + +// other processes that try to acquire a lock on 'lock identifier' will fail + +// do some stuff + +$lock->release(); + +// other processes can now acquire a lock on 'lock identifier' +``` + +### Auto release + +`$lock->release()` is called automatically when the lock is destroyed so you don't need to manually release it at the end of a script or if it goes out of scope. + +```php +use TH\RedisLock\RedisSimpleLockFactory; + +function batch() { + $redisClient = new \Predis\Client; + $factory = new RedisSimpleLockFactory($redisClient); + $lock = $factory->create('lock identifier'); + $lock->acquire(); + + // lot of stuff +} + +batch(); + +// the lock will be released here even if $lock->release() is not called in batch() +``` + +## Limitations + +### Validity time + +If a client crashes before releasing the lock (or forget to release it), no other clients would be able to acquire the lock again. To avoid Deadlock, `RedisSimpleLock` locks have a validity time at witch the lock key will expire. But be careful, if the operation is too long, another client might acquire the lock too. + +### Mutual exclusion + +Because `RedisSimpleLock` does not implements the [RedLock algorithm](http://redis.io/topics/distlock), it have a limitation : with a master slave replication, a race condition can occurs when the master crashes before the lock key is transmitted to the slave. In this case a second client could acquire the same lock. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..7613857 --- /dev/null +++ b/composer.json @@ -0,0 +1,23 @@ +{ + "name": "texthtml/php-lock-redis", + "description" : "redis lock", + "license": "aGPLv3", + "type": "library", + "autoload": { + "psr-4": { "TH\\RedisLock\\": "src" } + }, + "authors": [ + { + "name": "Mathieu Rochette", + "email": "mathieu@rochette.cc" + } + ], + "require-dev": { + "phpunit/phpunit": "~4.0" + }, + "require": { + "texthtml/php-lock": "~2.0", + "predis/predis": "~1.0", + "psr/log": "~1.0" + } +} diff --git a/src/RedisSimpleLock.php b/src/RedisSimpleLock.php new file mode 100644 index 0000000..aa018e8 --- /dev/null +++ b/src/RedisSimpleLock.php @@ -0,0 +1,65 @@ +identifier = $identifier; + $this->client = $client; + $this->ttl = $ttl; + $this->logger = $logger ?: new NullLogger; + $this->id = mt_rand(); + } + + public function acquire() + { + $log_data = ["identifier" => $this->identifier]; + $response = $this->client->set($this->identifier, $this->id, "PX", $this->ttl, "NX"); + if (!$response instanceof Status || $response->getPayload() !== "OK") { + $this->logger->debug("could not acquire lock on {identifier}", $log_data); + + throw new Exception("Could not acquire lock on " . $this->identifier); + } + $this->logger->debug("lock acquired on {identifier}", $log_data); + } + + public function release() + { + $script = <<client->eval($script, 1, $this->identifier, $this->id)) { + $this->logger->debug("lock released on {identifier}", ["identifier" => $this->identifier]); + } + } + + public function __destruct() + { + $this->release(); + } +} diff --git a/src/RedisSimpleLockFactory.php b/src/RedisSimpleLockFactory.php new file mode 100644 index 0000000..abcb887 --- /dev/null +++ b/src/RedisSimpleLockFactory.php @@ -0,0 +1,34 @@ +client = $client; + $this->defaultTtl = $defaultTtl; + $this->logger = $logger ?: new NullLogger; + } + + /** + * Create a new RedisSimpleLock + * + * @param string $identifier the redis lock key + * @param integer $ttl lock time-to-live in milliseconds + * + * @return RedisSimpleLock + */ + public function create($identifier, $ttl = null) + { + return new RedisSimpleLock($identifier, $this->client, $ttl ?: $this->defaultTtl, $this->logger); + } +} diff --git a/tests/RedisSimpleLockFactoryTest.php b/tests/RedisSimpleLockFactoryTest.php new file mode 100644 index 0000000..35a821a --- /dev/null +++ b/tests/RedisSimpleLockFactoryTest.php @@ -0,0 +1,22 @@ +redisClient = new \Predis\Client(getenv('REDIS_URI')); + $this->redisClient->flushdb(); + } + + public function testCreateLock() + { + $factory = new RedisSimpleLockFactory($this->redisClient, 50); + $lock = $factory->create('lock identifier'); + $this->assertInstanceOf(RedisSimpleLock::class, $lock); + } +} diff --git a/tests/RedisSimpleLockTest.php b/tests/RedisSimpleLockTest.php new file mode 100644 index 0000000..251d686 --- /dev/null +++ b/tests/RedisSimpleLockTest.php @@ -0,0 +1,76 @@ +redisClient = new \Predis\Client(getenv("REDIS_URI")); + $this->redisClient->flushdb(); + } + + public function testLock() + { + $lock1 = new RedisSimpleLock("lock identifier", $this->redisClient, 50); + $lock2 = new RedisSimpleLock("lock identifier", $this->redisClient); + + $lock1->acquire(); + + // Only the second acquire is supposed to fail + $this->setExpectedException("Exception"); + $lock2->acquire(); + } + + public function testLockTtl() + { + $lock1 = new RedisSimpleLock("lock identifier", $this->redisClient, 50); + $lock2 = new RedisSimpleLock("lock identifier", $this->redisClient); + + $lock1->acquire(); + usleep(100000); + + // first lock sould have been released + $lock2->acquire(); + } + + public function testLockSafeRelease() + { + $lock1 = new RedisSimpleLock("lock identifier", $this->redisClient, 50); + $lock2 = new RedisSimpleLock("lock identifier", $this->redisClient); + + $lock1->acquire(); + usleep(100000); + $lock2->acquire(); + $lock1->release(); + + // lock should still exists + $this->assertTrue($this->redisClient->exists("lock identifier"), "Lock should not have been released"); + } + + public function testLockRelease() + { + $lock1 = new RedisSimpleLock("lock identifier", $this->redisClient, 50); + $lock2 = new RedisSimpleLock("lock identifier", $this->redisClient); + + $lock1->acquire(); + $lock1->release(); + + // first lock sould have been released + $lock2->acquire(); + } + + public function testLockAutoRelease() + { + $lock1 = new RedisSimpleLock("lock identifier", $this->redisClient, 50); + $lock2 = new RedisSimpleLock("lock identifier", $this->redisClient); + + $lock1->acquire(); + unset($lock1); + + // first lock sould have been released + $lock2->acquire(); + } +}