Skip to content

Commit 3ca295c

Browse files
authored
Merge Redis mutex implementations into single RedisMutex class (#69)
1 parent 1ebd25c commit 3ca295c

14 files changed

+847
-919
lines changed

LICENSE

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
The MIT License (MIT)
22

3-
Copyright (c) 2024 Willem Stuursma-Ruwen
3+
Copyright (c) 2024 Markus Malkusch, Willem Stuursma-Ruwen, Michael Voříšek and GitHub contributors
44

55
Permission is hereby granted, free of charge, to any person obtaining a copy
66
of this software and associated documentation files (the "Software"), to deal

README.md

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@
1010
[![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)
1111
[![License](https://poser.pugx.org/malkusch/lock/license)](https://packagist.org/packages/malkusch/lock)
1212

13-
This library helps executing critical code in concurrent situations.
13+
This library helps executing critical code in concurrent situations in serialized fashion.
1414

15-
php-lock/lock follows semantic versioning. Read more on [semver.org][1].
15+
php-lock/lock follows [semantic versioning][1].
1616

1717
----
1818

@@ -164,8 +164,7 @@ implementations or create/extend your own implementation.
164164

165165
- [`FlockMutex`](#flockmutex)
166166
- [`MemcachedMutex`](#memcachedmutex)
167-
- [`PHPRedisMutex`](#phpredismutex)
168-
- [`PredisMutex`](#predismutex)
167+
- [`RedisMutex`](#redismutex)
169168
- [`SemaphoreMutex`](#semaphoremutex)
170169
- [`TransactionalMutex`](#transactionalmutex)
171170
- [`MySQLMutex`](#mysqlmutex)
@@ -195,7 +194,7 @@ extension if possible or busy waiting if not.
195194
#### MemcachedMutex
196195

197196
The **MemcachedMutex** is a spinlock implementation which uses the
198-
[`Memcached` API](http://php.net/manual/en/book.memcached.php).
197+
[`Memcached` extension](http://php.net/manual/en/book.memcached.php).
199198

200199
Example:
201200
```php
@@ -213,13 +212,14 @@ $mutex->synchronized(function () use ($bankAccount, $amount) {
213212
});
214213
```
215214

216-
#### PHPRedisMutex
215+
#### RedisMutex
217216

218-
The **PHPRedisMutex** is the distributed lock implementation of
219-
[RedLock](http://redis.io/topics/distlock) which uses the
220-
[`phpredis` extension](https://github.com/phpredis/phpredis).
217+
The **RedisMutex** is the distributed lock implementation of
218+
[RedLock](http://redis.io/topics/distlock) which supports the
219+
[`phpredis` extension](https://github.com/phpredis/phpredis)
220+
or [`Predis` API](https://github.com/nrk/predis).
221221

222-
This implementation requires at least `phpredis-2.2.4`.
222+
Both Redis and Valkey servers are supported.
223223

224224
If used with a cluster of Redis servers, acquiring and releasing locks will
225225
continue to function as long as a majority of the servers still works.
@@ -228,29 +228,9 @@ Example:
228228
```php
229229
$redis = new \Redis();
230230
$redis->connect('localhost');
231+
// OR $redis = new \Predis\Client('redis://localhost');
231232

232-
$mutex = new PHPRedisMutex([$redis], 'balance');
233-
$mutex->synchronized(function () use ($bankAccount, $amount) {
234-
$balance = $bankAccount->getBalance();
235-
$balance -= $amount;
236-
if ($balance < 0) {
237-
throw new \DomainException('You have no credit');
238-
}
239-
$bankAccount->setBalance($balance);
240-
});
241-
```
242-
243-
#### PredisMutex
244-
245-
The **PredisMutex** is the distributed lock implementation of
246-
[RedLock](http://redis.io/topics/distlock) which uses the
247-
[`Predis` API](https://github.com/nrk/predis).
248-
249-
Example:
250-
```php
251-
$redis = new \Predis\Client('redis://localhost');
252-
253-
$mutex = new PredisMutex([$redis], 'balance');
233+
$mutex = new RedisMutex([$redis], 'balance');
254234
$mutex->synchronized(function () use ($bankAccount, $amount) {
255235
$balance = $bankAccount->getBalance();
256236
$balance -= $amount;
@@ -316,6 +296,8 @@ The **MySQLMutex** uses MySQL's
316296
[`GET_LOCK`](https://dev.mysql.com/doc/refman/9.0/en/locking-functions.html#function_get-lock)
317297
function.
318298

299+
Both MySQL and MariaDB servers are supported.
300+
319301
It supports timeouts. If the connection to the database server is lost or
320302
interrupted, the lock is automatically released.
321303

@@ -366,6 +348,14 @@ $mutex->synchronized(function () use ($bankAccount, $amount) {
366348
});
367349
```
368350

351+
## Authors
352+
353+
Since year 2015 the development was led by Markus Malkusch, Willem Stuursma-Ruwen, Michael Voříšek and many GitHub contributors.
354+
355+
Currently this library is maintained by Michael Voříšek - [GitHub][https://github.com/mvorisek] and [LinkedIn][https://www.linkedin.com/mvorisek].
356+
357+
Commercial support is available.
358+
369359
## License
370360

371361
This project is free and is licensed under the MIT.

phpstan.neon.dist

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ parameters:
1212
ignoreErrors:
1313
# TODO
1414
-
15-
path: 'src/Mutex/RedisMutex.php'
15+
path: 'src/Mutex/AbstractRedlockMutex.php'
1616
identifier: if.condNotBoolean
1717
message: '~^Only booleans are allowed in an if condition, mixed given\.$~'
1818
count: 1
@@ -21,16 +21,6 @@ parameters:
2121
identifier: if.condNotBoolean
2222
message: '~^Only booleans are allowed in an if condition, mixed given\.$~'
2323
count: 1
24-
-
25-
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)\(\)$~'
26-
identifier: method.childParameterType
27-
path: 'src/Mutex/PHPRedisMutex.php'
28-
count: 2
29-
-
30-
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)\(\)$~'
31-
identifier: method.childParameterType
32-
path: 'src/Mutex/PredisMutex.php'
33-
count: 2
3424
-
3525
path: 'tests/Mutex/*Test.php'
3626
identifier: empty.notAllowed

src/Mutex/AbstractRedlockMutex.php

Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Malkusch\Lock\Mutex;
6+
7+
use Malkusch\Lock\Exception\LockAcquireException;
8+
use Malkusch\Lock\Exception\LockReleaseException;
9+
use Malkusch\Lock\Util\LockUtil;
10+
use Psr\Log\LoggerAwareInterface;
11+
use Psr\Log\LoggerAwareTrait;
12+
use Psr\Log\NullLogger;
13+
14+
/**
15+
* Distributed mutex based on the Redlock algorithm.
16+
*
17+
* @template TClient of object
18+
*
19+
* @see http://redis.io/topics/distlock
20+
*/
21+
abstract class AbstractRedlockMutex extends AbstractSpinlockMutex implements LoggerAwareInterface
22+
{
23+
use LoggerAwareTrait;
24+
25+
/** @var string The random value token for key identification */
26+
private $token;
27+
28+
/** @var array<int, TClient> */
29+
private $clients;
30+
31+
/**
32+
* Sets the Redis APIs.
33+
*
34+
* The Redis APIs needs to be connected. I.e. Redis::connect() was
35+
* called already.
36+
*
37+
* @param array<int, TClient> $clients
38+
* @param float $timeout The timeout in seconds a lock expires
39+
*
40+
* @throws \LengthException The timeout must be greater than 0
41+
*/
42+
public function __construct(array $clients, string $name, float $timeout = 3)
43+
{
44+
parent::__construct($name, $timeout);
45+
46+
$this->clients = $clients;
47+
$this->logger = new NullLogger();
48+
}
49+
50+
#[\Override]
51+
protected function acquire(string $key, float $expire): bool
52+
{
53+
// 1. This differs from the specification to avoid an overflow on 32-Bit systems.
54+
$time = microtime(true);
55+
56+
// 2.
57+
$acquired = 0;
58+
$errored = 0;
59+
$this->token = LockUtil::getInstance()->makeRandomToken();
60+
$exception = null;
61+
foreach ($this->clients as $index => $client) {
62+
try {
63+
if ($this->add($client, $key, $this->token, $expire)) {
64+
++$acquired;
65+
}
66+
} catch (LockAcquireException $exception) {
67+
// todo if there is only one redis server, throw immediately.
68+
$context = [
69+
'key' => $key,
70+
'index' => $index,
71+
'token' => $this->token,
72+
'exception' => $exception,
73+
];
74+
$this->logger->warning('Could not set {key} = {token} at server #{index}', $context);
75+
76+
++$errored;
77+
}
78+
}
79+
80+
// 3.
81+
$elapsedTime = microtime(true) - $time;
82+
$isAcquired = $this->isMajority($acquired) && $elapsedTime <= $expire;
83+
84+
if ($isAcquired) {
85+
// 4.
86+
return true;
87+
}
88+
89+
// 5.
90+
$this->release($key);
91+
92+
// In addition to RedLock it's an exception if too many servers fail.
93+
if (!$this->isMajority(count($this->clients) - $errored)) {
94+
assert($exception !== null); // The last exception for some context.
95+
96+
throw new LockAcquireException(
97+
'It\'s not possible to acquire a lock because at least half of the Redis server are not available',
98+
LockAcquireException::REDIS_NOT_ENOUGH_SERVERS,
99+
$exception
100+
);
101+
}
102+
103+
return false;
104+
}
105+
106+
#[\Override]
107+
protected function release(string $key): bool
108+
{
109+
/*
110+
* All Redis commands must be analyzed before execution to determine which keys the command will operate on. In
111+
* order for this to be true for EVAL, keys must be passed explicitly.
112+
*
113+
* @link https://redis.io/commands/set
114+
*/
115+
$script = 'if redis.call("get", KEYS[1]) == ARGV[1] then
116+
return redis.call("del", KEYS[1])
117+
else
118+
return 0
119+
end
120+
';
121+
$released = 0;
122+
foreach ($this->clients as $index => $client) {
123+
try {
124+
if ($this->evalScript($client, $script, 1, [$key, $this->token])) {
125+
++$released;
126+
}
127+
} catch (LockReleaseException $e) {
128+
// todo throw if there is only one redis server
129+
$context = [
130+
'key' => $key,
131+
'index' => $index,
132+
'token' => $this->token,
133+
'exception' => $e,
134+
];
135+
$this->logger->warning('Could not unset {key} = {token} at server #{index}', $context);
136+
}
137+
}
138+
139+
return $this->isMajority($released);
140+
}
141+
142+
/**
143+
* Returns if a count is the majority of all servers.
144+
*
145+
* @return bool True if the count is the majority
146+
*/
147+
private function isMajority(int $count): bool
148+
{
149+
return $count > count($this->clients) / 2;
150+
}
151+
152+
/**
153+
* Sets the key only if such key doesn't exist at the server yet.
154+
*
155+
* @param TClient $client
156+
* @param float $expire The TTL seconds
157+
*
158+
* @return bool True if the key was set
159+
*/
160+
abstract protected function add($client, string $key, string $value, float $expire): bool;
161+
162+
/**
163+
* @param TClient $client
164+
* @param string $script The Lua script
165+
* @param int $numkeys The number of values in $arguments that represent Redis key names
166+
* @param list<mixed> $arguments Keys and values
167+
*
168+
* @return mixed The script result, or false if executing failed
169+
*
170+
* @throws LockReleaseException An unexpected error happened
171+
*/
172+
abstract protected function evalScript($client, string $script, int $numkeys, array $arguments);
173+
}

0 commit comments

Comments
 (0)