Skip to content

Add Predis timeout #31

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/Lock/BasicLockInformationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ public function getLockInformation()
$hostname = gethostname();

$params = array();
$params[] = $pid;
$params[] = $hostname;
$params['pid'] = $pid;
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had to give these keys because now that there can be different information providers the order, length, and contents of the lock values will vary. The predis lock adds an 'expiration' key to this so the structure needs to be known.
Plus it just makes sense to give these keys now that the data can be anything a provider wants to use.

$params['hostname'] = $hostname;

return $params;
}
Expand Down
86 changes: 80 additions & 6 deletions src/Lock/PredisRedisLock.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,20 @@
*
* @author Kamil Dziedzic <[email protected]>
*/
class PredisRedisLock extends LockAbstract
class PredisRedisLock extends LockAbstract implements LockExpirationInterface
{
/**
* Predis connection
*
* @var
* @var Predis\Client
*/
protected $client;

/**
* @var int Expiration time of the lock in seconds
*/
protected $expiration = 0;

/**
* @param $client Predis\Client
*/
Expand All @@ -35,18 +40,71 @@ public function __construct($client)
$this->client = $client;
}

/**
* @param int $expiration Expiration time of the lock in seconds
*/
public function setExpiration($expiration)
{
$this->expiration = $expiration;
}

/**
* @param string $name
* @param bool $blocking
* @return bool
*/
protected function getLock($name, $blocking)
{
if (!$this->client->setnx($name, serialize($this->getLockInformation()))) {
return false;
/**
* Perform the process recommended by Redis for acquiring a lock, from here: https://redis.io/commands/setnx
* We are "C4" in this example...
*
* 1. C4 sends SETNX lock.foo in order to acquire the lock (sets the value if it does not already exist).
* 2. The crashed client C3 still holds it, so Redis will reply with 0 to C4.
* 3. C4 sends GET lock.foo to check if the lock expired.
* If it is not, it will sleep for some time and retry from the start.
* 4. Instead, if the lock is expired because the Unix time at lock.foo is older than the current Unix time,
* C4 tries to perform:
* GETSET lock.foo <current Unix timestamp + lock timeout + 1>
* Because of the GETSET semantic, C4 can check if the old value stored at key is still an expired timestamp
* If it is, the lock was acquired.
* 5. If another client, for instance C5, was faster than C4 and acquired the lock with the GETSET operation,
* the C4 GETSET operation will return a non expired timestamp.
* C4 will simply restart from the first step. Note that even if C4 wrote they key and set the expiry time
* a few seconds in the future this is not a problem. C5's timeout will just be a few seconds later.
*/

$lockValue = $this->getLockInformation();
if ($this->expiration) {
// Add expiration timestamp to value stored in Redis.
$lockValue['expires'] = time() + $this->expiration;
}
$lockValue = serialize($lockValue);

return true;
if ($this->client->setnx($name, $lockValue)) {
return true;
}

// Check if the existing lock has an expiry time. If it does and it has expired, delete the lock.
if ($existingValue = $this->client->get($name)) {
$existingValue = unserialize($existingValue);
if (!empty($existingValue['expires']) && $existingValue['expires'] <= time()) {
// The existing lock has expired. We can delete it and take over.
$newExistingValue = unserialize($this->client->getset($name, $lockValue));

// GETSET atomically sets key to value and returns the old value that was stored at key.
// If the old value from getset does not still contain an expired timestamp
// another probably acquired the lock in the meantime.
if ($newExistingValue['expires'] > time()) {
return false;
}

// Got it!
return true;
}
}

return false;
}

/**
Expand All @@ -57,7 +115,7 @@ protected function getLock($name, $blocking)
*/
public function releaseLock($name)
{
if (isset($this->locks[$name]) && $this->client->del($name)) {
if (isset($this->locks[$name]) && $this->client->del(array($name))) {
unset($this->locks[$name]);

return true;
Expand All @@ -76,4 +134,20 @@ public function isLocked($name)
{
return null !== $this->client->get($name);
}

/**
* Forget a lock without releasing it
*
* @param string $name name of lock
* @return bool
*/
public function clearLock($name)
{
if (!isset($this->locks[$name])) {
return false;
}

unset($this->locks[$name]);
return true;
}
}
2 changes: 1 addition & 1 deletion src/Lock/ResolvedHostnameLockInformationProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ResolvedHostnameLockInformationProvider extends BasicLockInformationProvid
public function getLockInformation()
{
$params = parent::getLockInformation();
Copy link
Author

@antriver antriver Jul 15, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed the parent call - gatherInformation does not exist.

$params[] = gethostbyname(gethostname());
$params['hostIp'] = gethostbyname(gethostname());
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a key the same as above in BasicLockInformationProvider


return $params;
}
Expand Down
76 changes: 76 additions & 0 deletions tests/Lock/PredisRedisLockTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
<?php

namespace NinjaMutex\Tests\Lock;

use NinjaMutex\Lock\PredisRedisLock;
use NinjaMutex\Mutex;
use NinjaMutex\Tests\Mock\MockPredisClient;

class PredisRedisLockTest extends \NinjaMutex\Tests\AbstractTest
{
protected function createPredisClient()
{
return new MockPredisClient();
}

protected function createLock($predisClient)
{
return new PredisRedisLock($predisClient);
}

public function testAcquireLock()
{
$predis = $this->createPredisClient();
$lock = $this->createLock($predis);
$mutex = new Mutex('very-critical-stuff', $lock);
$this->assertTrue($mutex->acquireLock());
}

public function testAcquireLockFails()
{
$predis = $this->createPredisClient();

// Acquire lock in 1st instance - should succeed
$lock = $this->createLock($predis);
$mutex = new Mutex('very-critical-stuff', $lock);
$this->assertTrue($mutex->acquireLock());

// Acquire lock in 2nd instance - should fail instantly because 0 timeout
$lock2 = $this->createLock($predis);
$mutex2 = new Mutex('very-critical-stuff', $lock2);
$this->assertFalse($mutex2->acquireLock(0));
}

public function testAcquireLockSucceedsAfterReleased()
{
$predis = $this->createPredisClient();

// Acquire lock in 1st instance - should succeed
$lock = $this->createLock($predis);
$mutex = new Mutex('very-critical-stuff', $lock);
$this->assertTrue($mutex->acquireLock());

$this->assertTrue($mutex->releaseLock());

// Acquire lock in 2nd instance - should succeed because 1st lock had been released
$lock2 = $this->createLock($predis);
$mutex2 = new Mutex('very-critical-stuff', $lock2);
$this->assertTrue($mutex2->acquireLock(0));
}

public function testAcquireLockSucceedsAfterTimeout()
{
$predis = $this->createPredisClient();

// Acquire lock in 1st instance - should succeed
$lock = $this->createLock($predis);
$lock->setExpiration(2);
$mutex = new Mutex('very-critical-stuff', $lock);
$this->assertTrue($mutex->acquireLock());

// Acquire lock in 2nd instance - should succeed after 2 seconds
$lock2 = $this->createLock($predis);
$mutex2 = new Mutex('very-critical-stuff', $lock2);
$this->assertTrue($mutex2->acquireLock());
}
}
47 changes: 44 additions & 3 deletions tests/Mock/MockPredisClient.php
Original file line number Diff line number Diff line change
Expand Up @@ -70,16 +70,18 @@ public function get($key)
}

/**
* @param string $key
* @param string[] $keys
* @return bool
*/
public function del($key)
public function del(array $keys)
{
if (!$this->available) {
return false;
}

unset(self::$data[$key]);
foreach ($keys as $key) {
unset(self::$data[$key]);
}

return true;
}
Expand All @@ -91,4 +93,43 @@ public function setAvailable($available)
{
$this->available = (bool)$available;
}

/**
* @param $key
* @param $value
* @param null $expireResolution
* @param null $expireTTL
* @param null $flag
*
* @return bool
*/
public function set($key, $value, $expireResolution = null, $expireTTL = null, $flag = null)
{
if (!$this->available) {
return false;
}

self::$data[$key] = (string) $value;

return true;
}

/**
* @param $key
* @param $value
*
* @return string|null
*/
public function getset($key, $value)
{
if (!$this->available) {
return false;
}

$oldValue = $this->get($key);

$this->set($key, $value);

return $oldValue;
}
}