Skip to content

[RFC][lock] Introduce Shared Lock (or Read/Write Lock) #37752

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

Merged
merged 1 commit into from
Sep 12, 2020
Merged
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
1 change: 1 addition & 0 deletions src/Symfony/Component/Lock/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ CHANGELOG
-----

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

5.1.0
-----
Expand Down
49 changes: 48 additions & 1 deletion src/Symfony/Component/Lock/Lock.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
final class Lock implements LockInterface, LoggerAwareInterface
final class Lock implements SharedLockInterface, LoggerAwareInterface
{
use LoggerAwareTrait;

Expand Down Expand Up @@ -109,6 +109,53 @@ public function acquire(bool $blocking = false): bool
}
}

/**
* {@inheritdoc}
*/
public function acquireRead(bool $blocking = false): bool
{
try {
if (!$this->store instanceof SharedLockStoreInterface) {
throw new NotSupportedException(sprintf('The store "%s" does not support shared locks.', get_debug_type($this->store)));
}
if ($blocking) {
$this->store->waitAndSaveRead($this->key);
} else {
$this->store->saveRead($this->key);
}

$this->dirty = true;
$this->logger->debug('Successfully acquired the "{resource}" lock.', ['resource' => $this->key]);

if ($this->ttl) {
$this->refresh();
}

if ($this->key->isExpired()) {
try {
$this->release();
} catch (\Exception $e) {
// swallow exception to not hide the original issue
}
throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $this->key));
}

return true;
} catch (LockConflictedException $e) {
$this->dirty = false;
$this->logger->info('Failed to acquire the "{resource}" lock. Someone else already acquired the lock.', ['resource' => $this->key]);

if ($blocking) {
throw $e;
}

return false;
} catch (\Exception $e) {
$this->logger->notice('Failed to acquire the "{resource}" lock.', ['resource' => $this->key, 'exception' => $e]);
throw new LockAcquiringException(sprintf('Failed to acquire the "%s" lock.', $this->key), 0, $e);
}
}

/**
* {@inheritdoc}
*/
Expand Down
34 changes: 34 additions & 0 deletions src/Symfony/Component/Lock/SharedLockInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Lock;

use Symfony\Component\Lock\Exception\LockAcquiringException;
use Symfony\Component\Lock\Exception\LockConflictedException;

/**
* SharedLockInterface defines an interface to manipulate the status of a shared lock.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface SharedLockInterface extends LockInterface
{
/**
* Acquires the lock for reading. If the lock is acquired by someone else in write mode, the parameter `blocking`
* determines whether or not the call should block until the release of the lock.
*
* @return bool whether or not the lock had been acquired
*
* @throws LockConflictedException If the lock is acquired by someone else in blocking mode
* @throws LockAcquiringException If the lock can not be acquired
*/
public function acquireRead(bool $blocking = false);
}
36 changes: 36 additions & 0 deletions src/Symfony/Component/Lock/SharedLockStoreInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Lock;

use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\NotSupportedException;

/**
* @author Jérémy Derussé <jeremy@derusse.com>
*/
interface SharedLockStoreInterface extends PersistingStoreInterface
{
/**
* Stores the resource if it's not locked for reading by someone else.
*
* @throws NotSupportedException
* @throws LockConflictedException
*/
public function saveRead(Key $key);

/**
* Waits until a key becomes free for reading, then stores the resource.
*
* @throws LockConflictedException
*/
public function waitAndSaveRead(Key $key);
}
33 changes: 33 additions & 0 deletions src/Symfony/Component/Lock/Store/BlockingSharedLockStoreTrait.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Lock\Store;

use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Key;

trait BlockingSharedLockStoreTrait
{
abstract public function saveRead(Key $key);

public function waitAndSaveRead(Key $key)
{
while (true) {
try {
$this->saveRead($key);

return;
} catch (LockConflictedException $e) {
usleep((100 + random_int(-10, 10)) * 1000);
}
}
}
}
54 changes: 53 additions & 1 deletion src/Symfony/Component/Lock/Store/CombinedStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,29 @@
use Psr\Log\NullLogger;
use Symfony\Component\Lock\Exception\InvalidArgumentException;
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\NotSupportedException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\PersistingStoreInterface;
use Symfony\Component\Lock\SharedLockStoreInterface;
use Symfony\Component\Lock\Strategy\StrategyInterface;

/**
* CombinedStore is a PersistingStoreInterface implementation able to manage and synchronize several StoreInterfaces.
*
* @author Jérémy Derussé <jeremy@derusse.com>
*/
class CombinedStore implements PersistingStoreInterface, LoggerAwareInterface
class CombinedStore implements SharedLockStoreInterface, LoggerAwareInterface
{
use BlockingSharedLockStoreTrait;
use ExpiringStoreTrait;
use LoggerAwareTrait;

/** @var PersistingStoreInterface[] */
private $stores;
/** @var StrategyInterface */
private $strategy;
/** @var SharedLockStoreInterface[] */
private $sharedLockStores;

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

public function saveRead(Key $key)
{
if (null === $this->sharedLockStores) {
$this->sharedLockStores = [];
foreach ($this->stores as $store) {
if ($store instanceof SharedLockStoreInterface) {
$this->sharedLockStores[] = $store;
}
}
}

$successCount = 0;
$storesCount = \count($this->stores);
$failureCount = $storesCount - \count($this->sharedLockStores);

if (!$this->strategy->canBeMet($failureCount, $storesCount)) {
throw new NotSupportedException(sprintf('The store "%s" does not contains enough compatible store to met the requirements.', get_debug_type($this)));
}

foreach ($this->sharedLockStores as $store) {
try {
$store->saveRead($key);
++$successCount;
} catch (\Exception $e) {
$this->logger->debug('One store failed to save the "{resource}" lock.', ['resource' => $key, 'store' => $store, 'exception' => $e]);
++$failureCount;
}

if (!$this->strategy->canBeMet($failureCount, $storesCount)) {
break;
}
}

$this->checkNotExpired($key);

if ($this->strategy->isMet($successCount, $storesCount)) {
return;
}

$this->logger->info('Failed to store the "{resource}" lock. Quorum has not been met.', ['resource' => $key, 'success' => $successCount, 'failure' => $failureCount]);

// clean up potential locks
$this->delete($key);

throw new LockConflictedException();
}

/**
* {@inheritdoc}
*/
Expand Down
70 changes: 47 additions & 23 deletions src/Symfony/Component/Lock/Store/FlockStore.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
use Symfony\Component\Lock\Exception\LockConflictedException;
use Symfony\Component\Lock\Exception\LockStorageException;
use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\SharedLockStoreInterface;

/**
* FlockStore is a PersistingStoreInterface implementation using the FileSystem flock.
Expand All @@ -27,7 +28,7 @@
* @author Romain Neutron <imprec@gmail.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
class FlockStore implements BlockingStoreInterface
class FlockStore implements BlockingStoreInterface, SharedLockStoreInterface
{
private $lockPath;

Expand All @@ -53,54 +54,77 @@ public function __construct(string $lockPath = null)
*/
public function save(Key $key)
{
$this->lock($key, false);
$this->lock($key, false, false);
}

/**
* {@inheritdoc}
*/
public function saveRead(Key $key)
{
$this->lock($key, true, false);
}

/**
* {@inheritdoc}
*/
public function waitAndSave(Key $key)
{
$this->lock($key, true);
$this->lock($key, false, true);
}

private function lock(Key $key, bool $blocking)
/**
* {@inheritdoc}
*/
public function waitAndSaveRead(Key $key)
{
$this->lock($key, true, true);
}

private function lock(Key $key, bool $read, bool $blocking)
{
$handle = null;
// The lock is maybe already acquired.
if ($key->hasState(__CLASS__)) {
return;
[$stateRead, $handle] = $key->getState(__CLASS__);
// Check for promotion or demotion
if ($stateRead === $read) {
return;
}
}

$fileName = sprintf('%s/sf.%s.%s.lock',
$this->lockPath,
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
strtr(substr(base64_encode(hash('sha256', $key, true)), 0, 7), '/', '_')
);

// Silence error reporting
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
if (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
if ($handle = fopen($fileName, 'x')) {
chmod($fileName, 0666);
} elseif (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
usleep(100); // Give some time for chmod() to complete
$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r');
if (!$handle) {
$fileName = sprintf('%s/sf.%s.%s.lock',
$this->lockPath,
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
strtr(substr(base64_encode(hash('sha256', $key, true)), 0, 7), '/', '_')
);

// Silence error reporting
set_error_handler(function ($type, $msg) use (&$error) { $error = $msg; });
if (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
if ($handle = fopen($fileName, 'x')) {
chmod($fileName, 0666);
} elseif (!$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r')) {
usleep(100); // Give some time for chmod() to complete
$handle = fopen($fileName, 'r+') ?: fopen($fileName, 'r');
}
}
restore_error_handler();
}
restore_error_handler();

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

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

$key->setState(__CLASS__, $handle);
$key->setState(__CLASS__, [$read, $handle]);
}

/**
Expand All @@ -121,7 +145,7 @@ public function delete(Key $key)
return;
}

$handle = $key->getState(__CLASS__);
$handle = $key->getState(__CLASS__)[1];

flock($handle, \LOCK_UN | \LOCK_NB);
fclose($handle);
Expand Down
Loading