Skip to content

Commit ceecc8d

Browse files
committed
Add expirations to Predis lock
1 parent 916c36b commit ceecc8d

5 files changed

+204
-14
lines changed

src/Lock/BasicLockInformationProvider.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,8 @@ public function getLockInformation()
2222
$hostname = gethostname();
2323

2424
$params = array();
25-
$params[] = $pid;
26-
$params[] = $hostname;
25+
$params['pid'] = $pid;
26+
$params['hostname'] = $hostname;
2727

2828
return $params;
2929
}

src/Lock/PredisRedisLock.php

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,20 @@
1616
*
1717
* @author Kamil Dziedzic <[email protected]>
1818
*/
19-
class PredisRedisLock extends LockAbstract
19+
class PredisRedisLock extends LockAbstract implements LockExpirationInterface
2020
{
2121
/**
2222
* Predis connection
2323
*
24-
* @var
24+
* @var Predis\Client
2525
*/
2626
protected $client;
2727

28+
/**
29+
* @var int Expiration time of the lock in seconds
30+
*/
31+
protected $expiration = 0;
32+
2833
/**
2934
* @param $client Predis\Client
3035
*/
@@ -35,18 +40,71 @@ public function __construct(Predis\Client $client)
3540
$this->client = $client;
3641
}
3742

43+
/**
44+
* @param int $expiration Expiration time of the lock in seconds
45+
*/
46+
public function setExpiration($expiration)
47+
{
48+
$this->expiration = $expiration;
49+
}
50+
3851
/**
3952
* @param string $name
4053
* @param bool $blocking
4154
* @return bool
4255
*/
4356
protected function getLock($name, $blocking)
4457
{
45-
if (!$this->client->setnx($name, serialize($this->getLockInformation()))) {
46-
return false;
58+
/**
59+
* Perform the process recommended by Redis for acquiring a lock, from here: https://redis.io/commands/setnx
60+
* We are "C4" in this example...
61+
*
62+
* 1. C4 sends SETNX lock.foo in order to acquire the lock (sets the value if it does not already exist).
63+
* 2. The crashed client C3 still holds it, so Redis will reply with 0 to C4.
64+
* 3. C4 sends GET lock.foo to check if the lock expired.
65+
* If it is not, it will sleep for some time and retry from the start.
66+
* 4. Instead, if the lock is expired because the Unix time at lock.foo is older than the current Unix time,
67+
* C4 tries to perform:
68+
* GETSET lock.foo <current Unix timestamp + lock timeout + 1>
69+
* Because of the GETSET semantic, C4 can check if the old value stored at key is still an expired timestamp
70+
* If it is, the lock was acquired.
71+
* 5. If another client, for instance C5, was faster than C4 and acquired the lock with the GETSET operation,
72+
* the C4 GETSET operation will return a non expired timestamp.
73+
* C4 will simply restart from the first step. Note that even if C4 wrote they key and set the expiry time
74+
* a few seconds in the future this is not a problem. C5's timeout will just be a few seconds later.
75+
*/
76+
77+
$lockValue = $this->getLockInformation();
78+
if ($this->expiration) {
79+
// Add expiration timestamp to value stored in Redis.
80+
$lockValue['expires'] = time() + $this->expiration;
4781
}
82+
$lockValue = serialize($lockValue);
4883

49-
return true;
84+
if ($this->client->setnx($name, $lockValue)) {
85+
return true;
86+
}
87+
88+
// Check if the existing lock has an expiry time. If it does and it has expired, delete the lock.
89+
if ($existingValue = $this->client->get($name)) {
90+
$existingValue = unserialize($existingValue);
91+
if (!empty($existingValue['expires']) && $existingValue['expires'] <= time()) {
92+
// The existing lock has expired. We can delete it and take over.
93+
$newExistingValue = unserialize($this->client->getset($name, $lockValue));
94+
95+
// GETSET atomically sets key to value and returns the old value that was stored at key.
96+
// If the old value from getset does not still contain an expired timestamp
97+
// another probably acquired the lock in the meantime.
98+
if ($newExistingValue['expires'] > time()) {
99+
return false;
100+
}
101+
102+
// Got it!
103+
return true;
104+
}
105+
}
106+
107+
return false;
50108
}
51109

52110
/**
@@ -57,7 +115,7 @@ protected function getLock($name, $blocking)
57115
*/
58116
public function releaseLock($name)
59117
{
60-
if (isset($this->locks[$name]) && $this->client->del($name)) {
118+
if (isset($this->locks[$name]) && $this->client->del(array($name))) {
61119
unset($this->locks[$name]);
62120

63121
return true;
@@ -76,4 +134,20 @@ public function isLocked($name)
76134
{
77135
return null !== $this->client->get($name);
78136
}
137+
138+
/**
139+
* Forget a lock without releasing it
140+
*
141+
* @param string $name name of lock
142+
* @return bool
143+
*/
144+
public function clearLock($name)
145+
{
146+
if (!isset($this->locks[$name])) {
147+
return false;
148+
}
149+
150+
unset($this->locks[$name]);
151+
return true;
152+
}
79153
}

src/Lock/ResolvedHostnameLockInformationProvider.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,9 @@ class ResolvedHostnameLockInformationProvider extends BasicLockInformationProvid
2020
*/
2121
public function getLockInformation()
2222
{
23-
$params = parent::gatherInformation();
24-
$params[] = gethostbyname(gethostname());
23+
$params = parent::getLockInformation();
24+
$params['hostIp'] = gethostbyname(gethostname());
2525

2626
return $params;
2727
}
28-
2928
}

tests/Lock/PredisRedisLockTest.php

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php
2+
3+
namespace NinjaMutex\Tests\Lock;
4+
5+
use NinjaMutex\Lock\PredisRedisLock;
6+
use NinjaMutex\Mutex;
7+
use NinjaMutex\Tests\Mock\MockPredisClient;
8+
9+
class PredisRedisLockTest extends \NinjaMutex\Tests\AbstractTest
10+
{
11+
protected function createPredisClient()
12+
{
13+
return new MockPredisClient();
14+
}
15+
16+
protected function createLock($predisClient)
17+
{
18+
return new PredisRedisLock($predisClient);
19+
}
20+
21+
public function testAcquireLock()
22+
{
23+
$predis = $this->createPredisClient();
24+
$lock = $this->createLock($predis);
25+
$mutex = new Mutex('very-critical-stuff', $lock);
26+
$this->assertTrue($mutex->acquireLock());
27+
}
28+
29+
public function testAcquireLockFails()
30+
{
31+
$predis = $this->createPredisClient();
32+
33+
// Acquire lock in 1st instance - should succeed
34+
$lock = $this->createLock($predis);
35+
$mutex = new Mutex('very-critical-stuff', $lock);
36+
$this->assertTrue($mutex->acquireLock());
37+
38+
// Acquire lock in 2nd instance - should fail instantly because 0 timeout
39+
$lock2 = $this->createLock($predis);
40+
$mutex2 = new Mutex('very-critical-stuff', $lock2);
41+
$this->assertFalse($mutex2->acquireLock(0));
42+
}
43+
44+
public function testAcquireLockSucceedsAfterReleased()
45+
{
46+
$predis = $this->createPredisClient();
47+
48+
// Acquire lock in 1st instance - should succeed
49+
$lock = $this->createLock($predis);
50+
$mutex = new Mutex('very-critical-stuff', $lock);
51+
$this->assertTrue($mutex->acquireLock());
52+
53+
$this->assertTrue($mutex->releaseLock());
54+
55+
// Acquire lock in 2nd instance - should succeed because 1st lock had been released
56+
$lock2 = $this->createLock($predis);
57+
$mutex2 = new Mutex('very-critical-stuff', $lock2);
58+
$this->assertTrue($mutex2->acquireLock(0));
59+
}
60+
61+
public function testAcquireLockSucceedsAfterTimeout()
62+
{
63+
$predis = $this->createPredisClient();
64+
65+
// Acquire lock in 1st instance - should succeed
66+
$lock = $this->createLock($predis);
67+
$lock->setExpiration(2);
68+
$mutex = new Mutex('very-critical-stuff', $lock);
69+
$this->assertTrue($mutex->acquireLock());
70+
71+
// Acquire lock in 2nd instance - should succeed after 2 seconds
72+
$lock2 = $this->createLock($predis);
73+
$mutex2 = new Mutex('very-critical-stuff', $lock2);
74+
$this->assertTrue($mutex2->acquireLock());
75+
}
76+
}

tests/Mock/MockPredisClient.php

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,18 @@ public function get($key)
7171
}
7272

7373
/**
74-
* @param string $key
74+
* @param string[] $keys
7575
* @return bool
7676
*/
77-
public function del($key)
77+
public function del(array $keys)
7878
{
7979
if (!$this->available) {
8080
return false;
8181
}
8282

83-
unset(self::$data[$key]);
83+
foreach ($keys as $key) {
84+
unset(self::$data[$key]);
85+
}
8486

8587
return true;
8688
}
@@ -92,4 +94,43 @@ public function setAvailable($available)
9294
{
9395
$this->available = (bool) $available;
9496
}
97+
98+
/**
99+
* @param $key
100+
* @param $value
101+
* @param null $expireResolution
102+
* @param null $expireTTL
103+
* @param null $flag
104+
*
105+
* @return bool
106+
*/
107+
public function set($key, $value, $expireResolution = null, $expireTTL = null, $flag = null)
108+
{
109+
if (!$this->available) {
110+
return false;
111+
}
112+
113+
self::$data[$key] = (string) $value;
114+
115+
return true;
116+
}
117+
118+
/**
119+
* @param $key
120+
* @param $value
121+
*
122+
* @return string|null
123+
*/
124+
public function getset($key, $value)
125+
{
126+
if (!$this->available) {
127+
return false;
128+
}
129+
130+
$oldValue = $this->get($key);
131+
132+
$this->set($key, $value);
133+
134+
return $oldValue;
135+
}
95136
}

0 commit comments

Comments
 (0)