Skip to content

Commit c9ad6c1

Browse files
authored
Introduce AbstractSpinlockWithTokenMutex class (#72)
1 parent 3a6025d commit c9ad6c1

16 files changed

+348
-158
lines changed

src/Exception/ExecutionOutsideLockException.php

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Malkusch\Lock\Exception;
66

7+
use Malkusch\Lock\Mutex\AbstractSpinlockMutex;
78
use Malkusch\Lock\Util\LockUtil;
89

910
/**
@@ -14,21 +15,21 @@
1415
*
1516
* Should only be used in contexts where the lock is being released.
1617
*
17-
* @see \Malkusch\Lock\Mutex\AbstractSpinlockMutex::unlock()
18+
* @see AbstractSpinlockMutex::unlock()
1819
*/
1920
class ExecutionOutsideLockException extends LockReleaseException
2021
{
2122
/**
22-
* @param float $elapsedTime Total elapsed time of the synchronized code callback execution
23-
* @param float $timeout The lock timeout in seconds
23+
* @param float $elapsedTime In seconds
24+
* @param float $expireTimeout In seconds
2425
*/
25-
public static function create(float $elapsedTime, float $timeout): self
26+
public static function create(float $elapsedTime, float $expireTimeout): self
2627
{
2728
return new self(\sprintf(
28-
'The code executed for %s seconds. But the timeout is %s seconds. The last %s seconds were executed outside of the lock.',
29+
'The code executed for %s seconds. But the expire timeout is %s seconds. The last %s seconds were executed outside of the lock.',
2930
LockUtil::getInstance()->formatTimeout($elapsedTime),
30-
LockUtil::getInstance()->formatTimeout($timeout),
31-
LockUtil::getInstance()->formatTimeout(round($elapsedTime, 6) - round($timeout, 6))
31+
LockUtil::getInstance()->formatTimeout($expireTimeout),
32+
LockUtil::getInstance()->formatTimeout(round($elapsedTime, 6) - round($expireTimeout, 6))
3233
));
3334
}
3435
}

src/Mutex/AbstractLockMutex.php

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@
88
use Malkusch\Lock\Exception\LockReleaseException;
99

1010
/**
11-
* Locking mutex.
12-
*
1311
* @internal
1412
*/
1513
abstract class AbstractLockMutex extends AbstractMutex

src/Mutex/AbstractRedlockMutex.php

Lines changed: 17 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -18,52 +18,51 @@
1818
*
1919
* @see http://redis.io/topics/distlock
2020
*/
21-
abstract class AbstractRedlockMutex extends AbstractSpinlockMutex implements LoggerAwareInterface
21+
abstract class AbstractRedlockMutex extends AbstractSpinlockWithTokenMutex implements LoggerAwareInterface
2222
{
2323
use LoggerAwareTrait;
2424

2525
/** @var array<int, TClient> */
2626
private array $clients;
2727

28-
private string $token;
29-
3028
/**
31-
* The Redis APIs needs to be connected. I.e. Redis::connect() was
29+
* The Redis instance needs to be connected. I.e. Redis::connect() was
3230
* called already.
3331
*
3432
* @param array<int, TClient> $clients
3533
* @param float $acquireTimeout In seconds
34+
* @param float $expireTimeout In seconds
3635
*/
37-
public function __construct(array $clients, string $name, float $acquireTimeout = 3)
36+
public function __construct(array $clients, string $name, float $acquireTimeout = 3, float $expireTimeout = \INF)
3837
{
39-
parent::__construct($name, $acquireTimeout);
38+
parent::__construct($name, $acquireTimeout, $expireTimeout);
4039

4140
$this->clients = $clients;
4241
$this->logger = new NullLogger();
4342
}
4443

4544
#[\Override]
46-
protected function acquire(string $key, float $expire): bool
45+
protected function acquireWithToken(string $key, float $expireTimeout)
4746
{
4847
// 1. This differs from the specification to avoid an overflow on 32-Bit systems.
49-
$time = microtime(true);
48+
$startTs = microtime(true);
5049

5150
// 2.
5251
$acquired = 0;
5352
$errored = 0;
54-
$this->token = LockUtil::getInstance()->makeRandomToken();
53+
$token = LockUtil::getInstance()->makeRandomToken();
5554
$exception = null;
5655
foreach ($this->clients as $index => $client) {
5756
try {
58-
if ($this->add($client, $key, $this->token, $expire)) {
57+
if ($this->add($client, $key, $token, $expireTimeout)) {
5958
++$acquired;
6059
}
6160
} catch (LockAcquireException $exception) {
6261
// todo if there is only one redis server, throw immediately.
6362
$context = [
6463
'key' => $key,
6564
'index' => $index,
66-
'token' => $this->token,
65+
'token' => $token,
6766
'exception' => $exception,
6867
];
6968
$this->logger->warning('Could not set {key} = {token} at server #{index}', $context);
@@ -73,16 +72,16 @@ protected function acquire(string $key, float $expire): bool
7372
}
7473

7574
// 3.
76-
$elapsedTime = microtime(true) - $time;
77-
$isAcquired = $this->isMajority($acquired) && $elapsedTime <= $expire;
75+
$elapsedTime = microtime(true) - $startTs;
76+
$isAcquired = $this->isMajority($acquired) && $elapsedTime <= $expireTimeout;
7877

7978
if ($isAcquired) {
8079
// 4.
81-
return true;
80+
return $token;
8281
}
8382

8483
// 5.
85-
$this->release($key);
84+
$this->releaseWithToken($key, $token);
8685

8786
// In addition to RedLock it's an exception if too many servers fail.
8887
if (!$this->isMajority(count($this->clients) - $errored)) {
@@ -99,7 +98,7 @@ protected function acquire(string $key, float $expire): bool
9998
}
10099

101100
#[\Override]
102-
protected function release(string $key): bool
101+
protected function releaseWithToken(string $key, string $token): bool
103102
{
104103
/*
105104
* All Redis commands must be analyzed before execution to determine which keys the command will operate on. In
@@ -117,15 +116,15 @@ protected function release(string $key): bool
117116
$released = 0;
118117
foreach ($this->clients as $index => $client) {
119118
try {
120-
if ($this->evalScript($client, $script, [$key], [$this->token])) {
119+
if ($this->evalScript($client, $script, [$key], [$token])) {
121120
++$released;
122121
}
123122
} catch (LockReleaseException $e) {
124123
// todo throw if there is only one redis server
125124
$context = [
126125
'key' => $key,
127126
'index' => $index,
128-
'token' => $this->token,
127+
'token' => $token,
129128
'exception' => $e,
130129
];
131130
$this->logger->warning('Could not unset {key} = {token} at server #{index}', $context);

src/Mutex/AbstractSpinlockMutex.php

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,22 @@
44

55
namespace Malkusch\Lock\Mutex;
66

7-
use Malkusch\Lock\Exception\ExecutionOutsideLockException;
87
use Malkusch\Lock\Exception\LockAcquireException;
98
use Malkusch\Lock\Exception\LockReleaseException;
109
use Malkusch\Lock\Util\LockUtil;
1110
use Malkusch\Lock\Util\Loop;
1211

1312
/**
1413
* Spinlock implementation.
15-
*
16-
* @internal
1714
*/
1815
abstract class AbstractSpinlockMutex extends AbstractLockMutex
1916
{
17+
/** @var non-falsy-string */
2018
private string $key;
2119

2220
/** In seconds */
2321
private float $acquireTimeout;
2422

25-
/** The timestamp when the lock was acquired */
26-
private ?float $acquiredTs = null;
27-
2823
/**
2924
* @param float $acquireTimeout In seconds
3025
*/
@@ -40,16 +35,7 @@ protected function lock(): void
4035
$loop = new Loop();
4136

4237
$loop->execute(function () use ($loop): void {
43-
$this->acquiredTs = microtime(true);
44-
45-
/*
46-
* The expiration timeout for the lock is increased by one second
47-
* to ensure that we delete only our keys. This will prevent the
48-
* case that this key expires before the timeout, and another process
49-
* acquires successfully the same key which would then be deleted
50-
* by this process.
51-
*/
52-
if ($this->acquire($this->key, $this->acquireTimeout + 1)) {
38+
if ($this->acquire($this->key)) {
5339
$loop->end();
5440
}
5541
}, $this->acquireTimeout);
@@ -58,15 +44,6 @@ protected function lock(): void
5844
#[\Override]
5945
protected function unlock(): void
6046
{
61-
$elapsedTime = microtime(true) - $this->acquiredTs;
62-
if ($elapsedTime > $this->acquireTimeout) {
63-
throw ExecutionOutsideLockException::create($elapsedTime, $this->acquireTimeout);
64-
}
65-
66-
/*
67-
* Worst case would still be one second before the key expires.
68-
* This guarantees that we don't delete a wrong key.
69-
*/
7047
if (!$this->release($this->key)) {
7148
throw new LockReleaseException('Failed to release the lock');
7249
}
@@ -75,17 +52,19 @@ protected function unlock(): void
7552
/**
7653
* Try to acquire a lock.
7754
*
78-
* @param float $expire In seconds
55+
* @param non-falsy-string $key
7956
*
8057
* @return bool True if the lock was acquired
8158
*
8259
* @throws LockAcquireException An unexpected error happened
8360
*/
84-
abstract protected function acquire(string $key, float $expire): bool;
61+
abstract protected function acquire(string $key): bool;
8562

8663
/**
8764
* Try to release a lock.
8865
*
66+
* @param non-falsy-string $key
67+
*
8968
* @return bool True if the lock was released
9069
*/
9170
abstract protected function release(string $key): bool;
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Malkusch\Lock\Mutex;
6+
7+
use Malkusch\Lock\Exception\ExecutionOutsideLockException;
8+
9+
/**
10+
* Spinlock implementation with expirable resource locking.
11+
*
12+
* Lock is acquired with an unique token that is verified when the lock is being released.
13+
*/
14+
abstract class AbstractSpinlockWithTokenMutex extends AbstractSpinlockMutex
15+
{
16+
/** In seconds */
17+
private float $expireTimeout;
18+
19+
private ?float $acquireTs = null;
20+
21+
/** @var non-falsy-string */
22+
private ?string $token = null;
23+
24+
/**
25+
* @param float $acquireTimeout In seconds
26+
* @param float $expireTimeout In seconds
27+
*/
28+
public function __construct(string $name, float $acquireTimeout = 3, float $expireTimeout = \INF)
29+
{
30+
parent::__construct($name, $acquireTimeout);
31+
32+
$this->expireTimeout = $expireTimeout;
33+
}
34+
35+
#[\Override]
36+
protected function acquire(string $key): bool
37+
{
38+
$acquireTs = microtime(true);
39+
40+
$token = $this->acquireWithToken($key, $this->expireTimeout);
41+
42+
if ($token === false) {
43+
return false;
44+
}
45+
46+
$this->acquireTs = $acquireTs;
47+
$this->token = $token;
48+
49+
return true;
50+
}
51+
52+
#[\Override]
53+
protected function release(string $key): bool
54+
{
55+
try {
56+
return $this->releaseWithToken($key, $this->token);
57+
} finally {
58+
try {
59+
$elapsedTime = microtime(true) - $this->acquireTs;
60+
if ($elapsedTime >= $this->expireTimeout) {
61+
throw ExecutionOutsideLockException::create($elapsedTime, $this->expireTimeout);
62+
}
63+
} finally {
64+
$this->token = null;
65+
$this->acquireTs = null;
66+
}
67+
}
68+
}
69+
70+
/**
71+
* Same as self::acquire() but with expire timeout and token.
72+
*
73+
* @param non-falsy-string $key
74+
* @param float $expireTimeout In seconds
75+
*
76+
* @return non-falsy-string|false
77+
*/
78+
abstract protected function acquireWithToken(string $key, float $expireTimeout);
79+
80+
/**
81+
* Same as self::release() but with expire timeout and token.
82+
*
83+
* @param non-falsy-string $key
84+
* @param non-falsy-string $token
85+
*/
86+
abstract protected function releaseWithToken(string $key, string $token): bool;
87+
}

0 commit comments

Comments
 (0)