Skip to content

Commit 0c34a16

Browse files
jderussefabpot
authored andcommitted
[RFC][lock] Introduce Shared Lock (or Read/Write Lock)
1 parent 4398358 commit 0c34a16

14 files changed

+766
-45
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ CHANGELOG
55
-----
66

77
* `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead.
8+
* added support for shared locks
89

910
5.1.0
1011
-----

Lock.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
*
2727
* @author Jérémy Derussé <jeremy@derusse.com>
2828
*/
29-
final class Lock implements LockInterface, LoggerAwareInterface
29+
final class Lock implements SharedLockInterface, LoggerAwareInterface
3030
{
3131
use LoggerAwareTrait;
3232

@@ -109,6 +109,53 @@ public function acquire(bool $blocking = false): bool
109109
}
110110
}
111111

112+
/**
113+
* {@inheritdoc}
114+
*/
115+
public function acquireRead(bool $blocking = false): bool
116+
{
117+
try {
118+
if (!$this->store instanceof SharedLockStoreInterface) {
119+
throw new NotSupportedException(sprintf('The store "%s" does not support shared locks.', get_debug_type($this->store)));
120+
}
121+
if ($blocking) {
122+
$this->store->waitAndSaveRead($this->key);
123+
} else {
124+
$this->store->saveRead($this->key);
125+
}
126+
127+
$this->dirty = true;
128+
$this->logger->debug('Successfully acquired the "{resource}" lock.', ['resource' => $this->key]);
129+
130+
if ($this->ttl) {
131+
$this->refresh();
132+
}
133+
134+
if ($this->key->isExpired()) {
135+
try {
136+
$this->release();
137+
} catch (\Exception $e) {
138+
// swallow exception to not hide the original issue
139+
}
140+
throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $this->key));
141+
}
142+
143+
return true;
144+
} catch (LockConflictedException $e) {
145+
$this->dirty = false;
146+
$this->logger->info('Failed to acquire the "{resource}" lock. Someone else already acquired the lock.', ['resource' => $this->key]);
147+
148+
if ($blocking) {
149+
throw $e;
150+
}
151+
152+
return false;
153+
} catch (\Exception $e) {
154+
$this->logger->notice('Failed to acquire the "{resource}" lock.', ['resource' => $this->key, 'exception' => $e]);
155+
throw new LockAcquiringException(sprintf('Failed to acquire the "%s" lock.', $this->key), 0, $e);
156+
}
157+
}
158+
112159
/**
113160
* {@inheritdoc}
114161
*/

SharedLockInterface.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Lock;
13+
14+
use Symfony\Component\Lock\Exception\LockAcquiringException;
15+
use Symfony\Component\Lock\Exception\LockConflictedException;
16+
17+
/**
18+
* SharedLockInterface defines an interface to manipulate the status of a shared lock.
19+
*
20+
* @author Jérémy Derussé <jeremy@derusse.com>
21+
*/
22+
interface SharedLockInterface extends LockInterface
23+
{
24+
/**
25+
* Acquires the lock for reading. If the lock is acquired by someone else in write mode, the parameter `blocking`
26+
* determines whether or not the call should block until the release of the lock.
27+
*
28+
* @return bool whether or not the lock had been acquired
29+
*
30+
* @throws LockConflictedException If the lock is acquired by someone else in blocking mode
31+
* @throws LockAcquiringException If the lock can not be acquired
32+
*/
33+
public function acquireRead(bool $blocking = false);
34+
}

SharedLockStoreInterface.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Lock;
13+
14+
use Symfony\Component\Lock\Exception\LockConflictedException;
15+
use Symfony\Component\Lock\Exception\NotSupportedException;
16+
17+
/**
18+
* @author Jérémy Derussé <jeremy@derusse.com>
19+
*/
20+
interface SharedLockStoreInterface extends PersistingStoreInterface
21+
{
22+
/**
23+
* Stores the resource if it's not locked for reading by someone else.
24+
*
25+
* @throws NotSupportedException
26+
* @throws LockConflictedException
27+
*/
28+
public function saveRead(Key $key);
29+
30+
/**
31+
* Waits until a key becomes free for reading, then stores the resource.
32+
*
33+
* @throws LockConflictedException
34+
*/
35+
public function waitAndSaveRead(Key $key);
36+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Lock\Store;
13+
14+
use Symfony\Component\Lock\Exception\LockConflictedException;
15+
use Symfony\Component\Lock\Key;
16+
17+
trait BlockingSharedLockStoreTrait
18+
{
19+
abstract public function saveRead(Key $key);
20+
21+
public function waitAndSaveRead(Key $key)
22+
{
23+
while (true) {
24+
try {
25+
$this->saveRead($key);
26+
27+
return;
28+
} catch (LockConflictedException $e) {
29+
usleep((100 + random_int(-10, 10)) * 1000);
30+
}
31+
}
32+
}
33+
}

Store/CombinedStore.php

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,29 @@
1616
use Psr\Log\NullLogger;
1717
use Symfony\Component\Lock\Exception\InvalidArgumentException;
1818
use Symfony\Component\Lock\Exception\LockConflictedException;
19+
use Symfony\Component\Lock\Exception\NotSupportedException;
1920
use Symfony\Component\Lock\Key;
2021
use Symfony\Component\Lock\PersistingStoreInterface;
22+
use Symfony\Component\Lock\SharedLockStoreInterface;
2123
use Symfony\Component\Lock\Strategy\StrategyInterface;
2224

2325
/**
2426
* CombinedStore is a PersistingStoreInterface implementation able to manage and synchronize several StoreInterfaces.
2527
*
2628
* @author Jérémy Derussé <jeremy@derusse.com>
2729
*/
28-
class CombinedStore implements PersistingStoreInterface, LoggerAwareInterface
30+
class CombinedStore implements SharedLockStoreInterface, LoggerAwareInterface
2931
{
32+
use BlockingSharedLockStoreTrait;
3033
use ExpiringStoreTrait;
3134
use LoggerAwareTrait;
3235

3336
/** @var PersistingStoreInterface[] */
3437
private $stores;
3538
/** @var StrategyInterface */
3639
private $strategy;
40+
/** @var SharedLockStoreInterface[] */
41+
private $sharedLockStores;
3742

3843
/**
3944
* @param PersistingStoreInterface[] $stores The list of synchronized stores
@@ -90,6 +95,53 @@ public function save(Key $key)
9095
throw new LockConflictedException();
9196
}
9297

98+
public function saveRead(Key $key)
99+
{
100+
if (null === $this->sharedLockStores) {
101+
$this->sharedLockStores = [];
102+
foreach ($this->stores as $store) {
103+
if ($store instanceof SharedLockStoreInterface) {
104+
$this->sharedLockStores[] = $store;
105+
}
106+
}
107+
}
108+
109+
$successCount = 0;
110+
$storesCount = \count($this->stores);
111+
$failureCount = $storesCount - \count($this->sharedLockStores);
112+
113+
if (!$this->strategy->canBeMet($failureCount, $storesCount)) {
114+
throw new NotSupportedException(sprintf('The store "%s" does not contains enough compatible store to met the requirements.', get_debug_type($this)));
115+
}
116+
117+
foreach ($this->sharedLockStores as $store) {
118+
try {
119+
$store->saveRead($key);
120+
++$successCount;
121+
} catch (\Exception $e) {
122+
$this->logger->debug('One store failed to save the "{resource}" lock.', ['resource' => $key, 'store' => $store, 'exception' => $e]);
123+
++$failureCount;
124+
}
125+
126+
if (!$this->strategy->canBeMet($failureCount, $storesCount)) {
127+
break;
128+
}
129+
}
130+
131+
$this->checkNotExpired($key);
132+
133+
if ($this->strategy->isMet($successCount, $storesCount)) {
134+
return;
135+
}
136+
137+
$this->logger->info('Failed to store the "{resource}" lock. Quorum has not been met.', ['resource' => $key, 'success' => $successCount, 'failure' => $failureCount]);
138+
139+
// clean up potential locks
140+
$this->delete($key);
141+
142+
throw new LockConflictedException();
143+
}
144+
93145
/**
94146
* {@inheritdoc}
95147
*/

Store/FlockStore.php

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use Symfony\Component\Lock\Exception\LockConflictedException;
1717
use Symfony\Component\Lock\Exception\LockStorageException;
1818
use Symfony\Component\Lock\Key;
19+
use Symfony\Component\Lock\SharedLockStoreInterface;
1920

2021
/**
2122
* FlockStore is a PersistingStoreInterface implementation using the FileSystem flock.
@@ -27,7 +28,7 @@
2728
* @author Romain Neutron <imprec@gmail.com>
2829
* @author Nicolas Grekas <p@tchwork.com>
2930
*/
30-
class FlockStore implements BlockingStoreInterface
31+
class FlockStore implements BlockingStoreInterface, SharedLockStoreInterface
3132
{
3233
private $lockPath;
3334

@@ -53,54 +54,77 @@ public function __construct(string $lockPath = null)
5354
*/
5455
public function save(Key $key)
5556
{
56-
$this->lock($key, false);
57+
$this->lock($key, false, false);
58+
}
59+
60+
/**
61+
* {@inheritdoc}
62+
*/
63+
public function saveRead(Key $key)
64+
{
65+
$this->lock($key, true, false);
5766
}
5867

5968
/**
6069
* {@inheritdoc}
6170
*/
6271
public function waitAndSave(Key $key)
6372
{
64-
$this->lock($key, true);
73+
$this->lock($key, false, true);
6574
}
6675

67-
private function lock(Key $key, bool $blocking)
76+
/**
77+
* {@inheritdoc}
78+
*/
79+
public function waitAndSaveRead(Key $key)
80+
{
81+
$this->lock($key, true, true);
82+
}
83+
84+
private function lock(Key $key, bool $read, bool $blocking)
6885
{
86+
$handle = null;
6987
// The lock is maybe already acquired.
7088
if ($key->hasState(__CLASS__)) {
71-
return;
89+
[$stateRead, $handle] = $key->getState(__CLASS__);
90+
// Check for promotion or demotion
91+
if ($stateRead === $read) {
92+
return;
93+
}
7294
}
7395

74-
$fileName = sprintf('%s/sf.%s.%s.lock',
75-
$this->lockPath,
76-
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
77-
strtr(substr(base64_encode(hash('sha256', $key, true)), 0, 7), '/', '_')
78-
);
79-
80-
// Silence error reporting
81-
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
82-
if (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
83-
if ($handle = fopen($fileName, 'x')) {
84-
chmod($fileName, 0666);
85-
} elseif (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
86-
usleep(100); // Give some time for chmod() to complete
87-
$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r');
96+
if (!$handle) {
97+
$fileName = sprintf('%s/sf.%s.%s.lock',
98+
$this->lockPath,
99+
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
100+
strtr(substr(base64_encode(hash('sha256', $key, true)), 0, 7), '/', '_')
101+
);
102+
103+
// Silence error reporting
104+
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
105+
if (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
106+
if ($handle = fopen($fileName, 'x')) {
107+
chmod($fileName, 0666);
108+
} elseif (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
109+
usleep(100); // Give some time for chmod() to complete
110+
$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r');
111+
}
88112
}
113+
restore_error_handler();
89114
}
90-
restore_error_handler();
91115

92116
if (!$handle) {
93117
throw new LockStorageException($error, 0, null);
94118
}
95119

96120
// On Windows, even if PHP doc says the contrary, LOCK_NB works, see
97121
// https://bugs.php.net/54129
98-
if (!flock($handle, \LOCK_EX | ($blocking ? 0 : \LOCK_NB))) {
122+
if (!flock($handle, ($read ? \LOCK_SH : \LOCK_EX) | ($blocking ? 0 : \LOCK_NB))) {
99123
fclose($handle);
100124
throw new LockConflictedException();
101125
}
102126

103-
$key->setState(__CLASS__, $handle);
127+
$key->setState(__CLASS__, [$read, $handle]);
104128
}
105129

106130
/**
@@ -121,7 +145,7 @@ public function delete(Key $key)
121145
return;
122146
}
123147

124-
$handle = $key->getState(__CLASS__);
148+
$handle = $key->getState(__CLASS__)[1];
125149

126150
flock($handle, \LOCK_UN | \LOCK_NB);
127151
fclose($handle);

0 commit comments

Comments
 (0)