[RFC][lock] Introduce Shared Lock (or Read/Write Lock)
This commit is contained in:
parent
8a89170284
commit
a7d51937f0
@ -5,6 +5,7 @@ CHANGELOG
|
|||||||
-----
|
-----
|
||||||
|
|
||||||
* `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead.
|
* `MongoDbStore` does not implement `BlockingStoreInterface` anymore, typehint against `PersistingStoreInterface` instead.
|
||||||
|
* added support for shared locks
|
||||||
|
|
||||||
5.1.0
|
5.1.0
|
||||||
-----
|
-----
|
||||||
|
@ -26,7 +26,7 @@ use Symfony\Component\Lock\Exception\NotSupportedException;
|
|||||||
*
|
*
|
||||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||||
*/
|
*/
|
||||||
final class Lock implements LockInterface, LoggerAwareInterface
|
final class Lock implements SharedLockInterface, LoggerAwareInterface
|
||||||
{
|
{
|
||||||
use LoggerAwareTrait;
|
use LoggerAwareTrait;
|
||||||
|
|
||||||
@ -109,6 +109,53 @@ final class Lock implements LockInterface, LoggerAwareInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
34
src/Symfony/Component/Lock/SharedLockInterface.php
Normal file
34
src/Symfony/Component/Lock/SharedLockInterface.php
Normal file
@ -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
src/Symfony/Component/Lock/SharedLockStoreInterface.php
Normal file
36
src/Symfony/Component/Lock/SharedLockStoreInterface.php
Normal file
@ -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);
|
||||||
|
}
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -16,8 +16,10 @@ use Psr\Log\LoggerAwareTrait;
|
|||||||
use Psr\Log\NullLogger;
|
use Psr\Log\NullLogger;
|
||||||
use Symfony\Component\Lock\Exception\InvalidArgumentException;
|
use Symfony\Component\Lock\Exception\InvalidArgumentException;
|
||||||
use Symfony\Component\Lock\Exception\LockConflictedException;
|
use Symfony\Component\Lock\Exception\LockConflictedException;
|
||||||
|
use Symfony\Component\Lock\Exception\NotSupportedException;
|
||||||
use Symfony\Component\Lock\Key;
|
use Symfony\Component\Lock\Key;
|
||||||
use Symfony\Component\Lock\PersistingStoreInterface;
|
use Symfony\Component\Lock\PersistingStoreInterface;
|
||||||
|
use Symfony\Component\Lock\SharedLockStoreInterface;
|
||||||
use Symfony\Component\Lock\Strategy\StrategyInterface;
|
use Symfony\Component\Lock\Strategy\StrategyInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -25,8 +27,9 @@ use Symfony\Component\Lock\Strategy\StrategyInterface;
|
|||||||
*
|
*
|
||||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||||
*/
|
*/
|
||||||
class CombinedStore implements PersistingStoreInterface, LoggerAwareInterface
|
class CombinedStore implements SharedLockStoreInterface, LoggerAwareInterface
|
||||||
{
|
{
|
||||||
|
use BlockingSharedLockStoreTrait;
|
||||||
use ExpiringStoreTrait;
|
use ExpiringStoreTrait;
|
||||||
use LoggerAwareTrait;
|
use LoggerAwareTrait;
|
||||||
|
|
||||||
@ -34,6 +37,8 @@ class CombinedStore implements PersistingStoreInterface, LoggerAwareInterface
|
|||||||
private $stores;
|
private $stores;
|
||||||
/** @var StrategyInterface */
|
/** @var StrategyInterface */
|
||||||
private $strategy;
|
private $strategy;
|
||||||
|
/** @var SharedLockStoreInterface[] */
|
||||||
|
private $sharedLockStores;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param PersistingStoreInterface[] $stores The list of synchronized stores
|
* @param PersistingStoreInterface[] $stores The list of synchronized stores
|
||||||
@ -90,6 +95,53 @@ class CombinedStore implements PersistingStoreInterface, LoggerAwareInterface
|
|||||||
throw new LockConflictedException();
|
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}
|
* {@inheritdoc}
|
||||||
*/
|
*/
|
||||||
|
@ -16,6 +16,7 @@ use Symfony\Component\Lock\Exception\InvalidArgumentException;
|
|||||||
use Symfony\Component\Lock\Exception\LockConflictedException;
|
use Symfony\Component\Lock\Exception\LockConflictedException;
|
||||||
use Symfony\Component\Lock\Exception\LockStorageException;
|
use Symfony\Component\Lock\Exception\LockStorageException;
|
||||||
use Symfony\Component\Lock\Key;
|
use Symfony\Component\Lock\Key;
|
||||||
|
use Symfony\Component\Lock\SharedLockStoreInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FlockStore is a PersistingStoreInterface implementation using the FileSystem flock.
|
* FlockStore is a PersistingStoreInterface implementation using the FileSystem flock.
|
||||||
@ -27,7 +28,7 @@ use Symfony\Component\Lock\Key;
|
|||||||
* @author Romain Neutron <imprec@gmail.com>
|
* @author Romain Neutron <imprec@gmail.com>
|
||||||
* @author Nicolas Grekas <p@tchwork.com>
|
* @author Nicolas Grekas <p@tchwork.com>
|
||||||
*/
|
*/
|
||||||
class FlockStore implements BlockingStoreInterface
|
class FlockStore implements BlockingStoreInterface, SharedLockStoreInterface
|
||||||
{
|
{
|
||||||
private $lockPath;
|
private $lockPath;
|
||||||
|
|
||||||
@ -53,7 +54,15 @@ class FlockStore implements BlockingStoreInterface
|
|||||||
*/
|
*/
|
||||||
public function save(Key $key)
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -61,16 +70,30 @@ class FlockStore implements BlockingStoreInterface
|
|||||||
*/
|
*/
|
||||||
public function waitAndSave(Key $key)
|
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.
|
// The lock is maybe already acquired.
|
||||||
if ($key->hasState(__CLASS__)) {
|
if ($key->hasState(__CLASS__)) {
|
||||||
|
[$stateRead, $handle] = $key->getState(__CLASS__);
|
||||||
|
// Check for promotion or demotion
|
||||||
|
if ($stateRead === $read) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!$handle) {
|
||||||
$fileName = sprintf('%s/sf.%s.%s.lock',
|
$fileName = sprintf('%s/sf.%s.%s.lock',
|
||||||
$this->lockPath,
|
$this->lockPath,
|
||||||
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
|
preg_replace('/[^a-z0-9\._-]+/i', '-', $key),
|
||||||
@ -88,6 +111,7 @@ class FlockStore implements BlockingStoreInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
restore_error_handler();
|
restore_error_handler();
|
||||||
|
}
|
||||||
|
|
||||||
if (!$handle) {
|
if (!$handle) {
|
||||||
throw new LockStorageException($error, 0, null);
|
throw new LockStorageException($error, 0, null);
|
||||||
@ -95,12 +119,12 @@ class FlockStore implements BlockingStoreInterface
|
|||||||
|
|
||||||
// On Windows, even if PHP doc says the contrary, LOCK_NB works, see
|
// On Windows, even if PHP doc says the contrary, LOCK_NB works, see
|
||||||
// https://bugs.php.net/54129
|
// 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);
|
fclose($handle);
|
||||||
throw new LockConflictedException();
|
throw new LockConflictedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$key->setState(__CLASS__, $handle);
|
$key->setState(__CLASS__, [$read, $handle]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -121,7 +145,7 @@ class FlockStore implements BlockingStoreInterface
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$handle = $key->getState(__CLASS__);
|
$handle = $key->getState(__CLASS__)[1];
|
||||||
|
|
||||||
flock($handle, \LOCK_UN | \LOCK_NB);
|
flock($handle, \LOCK_UN | \LOCK_NB);
|
||||||
fclose($handle);
|
fclose($handle);
|
||||||
|
@ -11,25 +11,31 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\Lock\Store;
|
namespace Symfony\Component\Lock\Store;
|
||||||
|
|
||||||
|
use Predis\Response\ServerException;
|
||||||
use Symfony\Component\Cache\Traits\RedisClusterProxy;
|
use Symfony\Component\Cache\Traits\RedisClusterProxy;
|
||||||
use Symfony\Component\Cache\Traits\RedisProxy;
|
use Symfony\Component\Cache\Traits\RedisProxy;
|
||||||
use Symfony\Component\Lock\Exception\InvalidArgumentException;
|
use Symfony\Component\Lock\Exception\InvalidArgumentException;
|
||||||
use Symfony\Component\Lock\Exception\InvalidTtlException;
|
use Symfony\Component\Lock\Exception\InvalidTtlException;
|
||||||
use Symfony\Component\Lock\Exception\LockConflictedException;
|
use Symfony\Component\Lock\Exception\LockConflictedException;
|
||||||
|
use Symfony\Component\Lock\Exception\LockStorageException;
|
||||||
use Symfony\Component\Lock\Key;
|
use Symfony\Component\Lock\Key;
|
||||||
use Symfony\Component\Lock\PersistingStoreInterface;
|
use Symfony\Component\Lock\PersistingStoreInterface;
|
||||||
|
use Symfony\Component\Lock\SharedLockStoreInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RedisStore is a PersistingStoreInterface implementation using Redis as store engine.
|
* RedisStore is a PersistingStoreInterface implementation using Redis as store engine.
|
||||||
*
|
*
|
||||||
* @author Jérémy Derussé <jeremy@derusse.com>
|
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||||
|
* @author Grégoire Pineau <lyrixx@lyrixx.info>
|
||||||
*/
|
*/
|
||||||
class RedisStore implements PersistingStoreInterface
|
class RedisStore implements SharedLockStoreInterface
|
||||||
{
|
{
|
||||||
use ExpiringStoreTrait;
|
use ExpiringStoreTrait;
|
||||||
|
use BlockingSharedLockStoreTrait;
|
||||||
|
|
||||||
private $redis;
|
private $redis;
|
||||||
private $initialTtl;
|
private $initialTtl;
|
||||||
|
private $supportTime;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \Redis|\RedisArray|\RedisCluster|RedisProxy|RedisClusterProxy|\Predis\ClientInterface $redisClient
|
* @param \Redis|\RedisArray|\RedisCluster|RedisProxy|RedisClusterProxy|\Predis\ClientInterface $redisClient
|
||||||
@ -55,17 +61,82 @@ class RedisStore implements PersistingStoreInterface
|
|||||||
public function save(Key $key)
|
public function save(Key $key)
|
||||||
{
|
{
|
||||||
$script = '
|
$script = '
|
||||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
local key = KEYS[1]
|
||||||
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
local uniqueToken = ARGV[2]
|
||||||
elseif redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
|
local ttl = tonumber(ARGV[3])
|
||||||
return 1
|
|
||||||
else
|
-- asserts the KEY is compatible with current version (old Symfony <5.2 BC)
|
||||||
return 0
|
if redis.call("TYPE", key).ok == "string" then
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
'.$this->getNowCode().'
|
||||||
|
|
||||||
|
-- Remove expired values
|
||||||
|
redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
|
||||||
|
|
||||||
|
-- is already acquired
|
||||||
|
if redis.call("ZSCORE", key, uniqueToken) then
|
||||||
|
-- is not WRITE lock and cannot be promoted
|
||||||
|
if not redis.call("ZSCORE", key, "__write__") and redis.call("ZCOUNT", key, "-inf", "+inf") > 1 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
elseif redis.call("ZCOUNT", key, "-inf", "+inf") > 0 then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call("ZADD", key, now + ttl, uniqueToken)
|
||||||
|
redis.call("ZADD", key, now + ttl, "__write__")
|
||||||
|
|
||||||
|
-- Extend the TTL of the key
|
||||||
|
local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]
|
||||||
|
redis.call("PEXPIREAT", key, maxExpiration)
|
||||||
|
|
||||||
|
return true
|
||||||
';
|
';
|
||||||
|
|
||||||
$key->reduceLifetime($this->initialTtl);
|
$key->reduceLifetime($this->initialTtl);
|
||||||
if (!$this->evaluate($script, (string) $key, [$this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) {
|
if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) {
|
||||||
|
throw new LockConflictedException();
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->checkNotExpired($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function saveRead(Key $key)
|
||||||
|
{
|
||||||
|
$script = '
|
||||||
|
local key = KEYS[1]
|
||||||
|
local uniqueToken = ARGV[2]
|
||||||
|
local ttl = tonumber(ARGV[3])
|
||||||
|
|
||||||
|
-- asserts the KEY is compatible with current version (old Symfony <5.2 BC)
|
||||||
|
if redis.call("TYPE", key).ok == "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
'.$this->getNowCode().'
|
||||||
|
|
||||||
|
-- Remove expired values
|
||||||
|
redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
|
||||||
|
|
||||||
|
-- lock not already acquired and a WRITE lock exists?
|
||||||
|
if not redis.call("ZSCORE", key, uniqueToken) and redis.call("ZSCORE", key, "__write__") then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call("ZADD", key, now + ttl, uniqueToken)
|
||||||
|
redis.call("ZREM", key, "__write__")
|
||||||
|
|
||||||
|
-- Extend the TTL of the key
|
||||||
|
local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]
|
||||||
|
redis.call("PEXPIREAT", key, maxExpiration)
|
||||||
|
|
||||||
|
return true
|
||||||
|
';
|
||||||
|
|
||||||
|
$key->reduceLifetime($this->initialTtl);
|
||||||
|
if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($this->initialTtl * 1000)])) {
|
||||||
throw new LockConflictedException();
|
throw new LockConflictedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -78,15 +149,37 @@ class RedisStore implements PersistingStoreInterface
|
|||||||
public function putOffExpiration(Key $key, float $ttl)
|
public function putOffExpiration(Key $key, float $ttl)
|
||||||
{
|
{
|
||||||
$script = '
|
$script = '
|
||||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
local key = KEYS[1]
|
||||||
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
local uniqueToken = ARGV[2]
|
||||||
else
|
local ttl = tonumber(ARGV[3])
|
||||||
return 0
|
|
||||||
|
-- asserts the KEY is compatible with current version (old Symfony <5.2 BC)
|
||||||
|
if redis.call("TYPE", key).ok == "string" then
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
'.$this->getNowCode().'
|
||||||
|
|
||||||
|
-- lock already acquired acquired?
|
||||||
|
if not redis.call("ZSCORE", key, uniqueToken) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call("ZADD", key, now + ttl, uniqueToken)
|
||||||
|
-- if the lock is also a WRITE lock, increase the TTL
|
||||||
|
if redis.call("ZSCORE", key, "__write__") then
|
||||||
|
redis.call("ZADD", key, now + ttl, "__write__")
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Extend the TTL of the key
|
||||||
|
local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]
|
||||||
|
redis.call("PEXPIREAT", key, maxExpiration)
|
||||||
|
|
||||||
|
return true
|
||||||
';
|
';
|
||||||
|
|
||||||
$key->reduceLifetime($ttl);
|
$key->reduceLifetime($ttl);
|
||||||
if (!$this->evaluate($script, (string) $key, [$this->getUniqueToken($key), (int) ceil($ttl * 1000)])) {
|
if (!$this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key), (int) ceil($ttl * 1000)])) {
|
||||||
throw new LockConflictedException();
|
throw new LockConflictedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -99,11 +192,28 @@ class RedisStore implements PersistingStoreInterface
|
|||||||
public function delete(Key $key)
|
public function delete(Key $key)
|
||||||
{
|
{
|
||||||
$script = '
|
$script = '
|
||||||
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
local key = KEYS[1]
|
||||||
return redis.call("DEL", KEYS[1])
|
local uniqueToken = ARGV[1]
|
||||||
else
|
|
||||||
return 0
|
-- asserts the KEY is compatible with current version (old Symfony <5.2 BC)
|
||||||
|
if redis.call("TYPE", key).ok == "string" then
|
||||||
|
return false
|
||||||
end
|
end
|
||||||
|
|
||||||
|
-- lock not already acquired
|
||||||
|
if not redis.call("ZSCORE", key, uniqueToken) then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
redis.call("ZREM", key, uniqueToken)
|
||||||
|
redis.call("ZREM", key, "__write__")
|
||||||
|
|
||||||
|
local maxExpiration = redis.call("ZREVRANGE", key, 0, 0, "WITHSCORES")[2]
|
||||||
|
if nil ~= maxExpiration then
|
||||||
|
redis.call("PEXPIREAT", key, maxExpiration)
|
||||||
|
end
|
||||||
|
|
||||||
|
return true
|
||||||
';
|
';
|
||||||
|
|
||||||
$this->evaluate($script, (string) $key, [$this->getUniqueToken($key)]);
|
$this->evaluate($script, (string) $key, [$this->getUniqueToken($key)]);
|
||||||
@ -114,7 +224,28 @@ class RedisStore implements PersistingStoreInterface
|
|||||||
*/
|
*/
|
||||||
public function exists(Key $key)
|
public function exists(Key $key)
|
||||||
{
|
{
|
||||||
return $this->redis->get((string) $key) === $this->getUniqueToken($key);
|
$script = '
|
||||||
|
local key = KEYS[1]
|
||||||
|
local uniqueToken = ARGV[2]
|
||||||
|
|
||||||
|
-- asserts the KEY is compatible with current version (old Symfony <5.2 BC)
|
||||||
|
if redis.call("TYPE", key).ok == "string" then
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
'.$this->getNowCode().'
|
||||||
|
|
||||||
|
-- Remove expired values
|
||||||
|
redis.call("ZREMRANGEBYSCORE", key, "-inf", now)
|
||||||
|
|
||||||
|
if redis.call("ZSCORE", key, uniqueToken) then
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
';
|
||||||
|
|
||||||
|
return (bool) $this->evaluate($script, (string) $key, [microtime(true), $this->getUniqueToken($key)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -130,15 +261,34 @@ class RedisStore implements PersistingStoreInterface
|
|||||||
$this->redis instanceof RedisProxy ||
|
$this->redis instanceof RedisProxy ||
|
||||||
$this->redis instanceof RedisClusterProxy
|
$this->redis instanceof RedisClusterProxy
|
||||||
) {
|
) {
|
||||||
return $this->redis->eval($script, array_merge([$resource], $args), 1);
|
$this->redis->clearLastError();
|
||||||
|
$result = $this->redis->eval($script, array_merge([$resource], $args), 1);
|
||||||
|
if (null !== $err = $this->redis->getLastError()) {
|
||||||
|
throw new LockStorageException($err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->redis instanceof \RedisArray) {
|
if ($this->redis instanceof \RedisArray) {
|
||||||
|
$client = $this->redis->_instance($this->redis->_target($resource));
|
||||||
|
$client->clearLastError();
|
||||||
|
$result = $client->eval($script, array_merge([$resource], $args), 1);
|
||||||
|
if (null !== $err = $client->getLastError()) {
|
||||||
|
throw new LockStorageException($err);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
|
||||||
return $this->redis->_instance($this->redis->_target($resource))->eval($script, array_merge([$resource], $args), 1);
|
return $this->redis->_instance($this->redis->_target($resource))->eval($script, array_merge([$resource], $args), 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($this->redis instanceof \Predis\ClientInterface) {
|
if ($this->redis instanceof \Predis\ClientInterface) {
|
||||||
|
try {
|
||||||
return $this->redis->eval(...array_merge([$script, 1, $resource], $args));
|
return $this->redis->eval(...array_merge([$script, 1, $resource], $args));
|
||||||
|
} catch (ServerException $e) {
|
||||||
|
throw new LockStorageException($e->getMessage(), $e->getCode(), $e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new InvalidArgumentException(sprintf('"%s()" expects being initialized with a Redis, RedisArray, RedisCluster or Predis\ClientInterface, "%s" given.', __METHOD__, get_debug_type($this->redis)));
|
throw new InvalidArgumentException(sprintf('"%s()" expects being initialized with a Redis, RedisArray, RedisCluster or Predis\ClientInterface, "%s" given.', __METHOD__, get_debug_type($this->redis)));
|
||||||
@ -153,4 +303,39 @@ class RedisStore implements PersistingStoreInterface
|
|||||||
|
|
||||||
return $key->getState(__CLASS__);
|
return $key->getState(__CLASS__);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function getNowCode(): string
|
||||||
|
{
|
||||||
|
if (null === $this->supportTime) {
|
||||||
|
// Redis < 5.0 does not support TIME (not deterministic) in script.
|
||||||
|
// https://redis.io/commands/eval#replicating-commands-instead-of-scripts
|
||||||
|
// This code asserts TIME can be use, otherwise will fallback to a timestamp generated by the PHP process.
|
||||||
|
$script = '
|
||||||
|
local now = redis.call("TIME")
|
||||||
|
redis.call("SET", KEYS[1], "1", "PX", 1)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
';
|
||||||
|
try {
|
||||||
|
$this->supportTime = 1 === $this->evaluate($script, 'symfony_check_support_time', []);
|
||||||
|
} catch (LockStorageException $e) {
|
||||||
|
if (false === strpos($e->getMessage(), 'commands not allowed after non deterministic')) {
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
$this->supportTime = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->supportTime) {
|
||||||
|
return '
|
||||||
|
local now = redis.call("TIME")
|
||||||
|
now = now[1] * 1000 + math.floor(now[2] / 1000)
|
||||||
|
';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '
|
||||||
|
local now = tonumber(ARGV[1])
|
||||||
|
now = math.floor(now * 1000)
|
||||||
|
';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -11,6 +11,11 @@
|
|||||||
|
|
||||||
namespace Symfony\Component\Lock\Tests\Store;
|
namespace Symfony\Component\Lock\Tests\Store;
|
||||||
|
|
||||||
|
use Symfony\Component\Cache\Traits\RedisClusterProxy;
|
||||||
|
use Symfony\Component\Cache\Traits\RedisProxy;
|
||||||
|
use Symfony\Component\Lock\Exception\InvalidArgumentException;
|
||||||
|
use Symfony\Component\Lock\Exception\LockConflictedException;
|
||||||
|
use Symfony\Component\Lock\Key;
|
||||||
use Symfony\Component\Lock\PersistingStoreInterface;
|
use Symfony\Component\Lock\PersistingStoreInterface;
|
||||||
use Symfony\Component\Lock\Store\RedisStore;
|
use Symfony\Component\Lock\Store\RedisStore;
|
||||||
|
|
||||||
@ -43,4 +48,83 @@ abstract class AbstractRedisStoreTest extends AbstractStoreTest
|
|||||||
{
|
{
|
||||||
return new RedisStore($this->getRedisConnection());
|
return new RedisStore($this->getRedisConnection());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testBackwardCompatibility()
|
||||||
|
{
|
||||||
|
$resource = uniqid(__METHOD__, true);
|
||||||
|
$key1 = new Key($resource);
|
||||||
|
$key2 = new Key($resource);
|
||||||
|
|
||||||
|
$oldStore = new Symfony51Store($this->getRedisConnection());
|
||||||
|
$newStore = $this->getStore();
|
||||||
|
|
||||||
|
$oldStore->save($key1);
|
||||||
|
$this->assertTrue($oldStore->exists($key1));
|
||||||
|
|
||||||
|
$this->expectException(LockConflictedException::class);
|
||||||
|
$newStore->save($key2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Symfony51Store
|
||||||
|
{
|
||||||
|
private $redis;
|
||||||
|
|
||||||
|
public function __construct($redis)
|
||||||
|
{
|
||||||
|
$this->redis = $redis;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(Key $key)
|
||||||
|
{
|
||||||
|
$script = '
|
||||||
|
if redis.call("GET", KEYS[1]) == ARGV[1] then
|
||||||
|
return redis.call("PEXPIRE", KEYS[1], ARGV[2])
|
||||||
|
elseif redis.call("SET", KEYS[1], ARGV[1], "NX", "PX", ARGV[2]) then
|
||||||
|
return 1
|
||||||
|
else
|
||||||
|
return 0
|
||||||
|
end
|
||||||
|
';
|
||||||
|
if (!$this->evaluate($script, (string) $key, [$this->getUniqueToken($key), (int) ceil(5 * 1000)])) {
|
||||||
|
throw new LockConflictedException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exists(Key $key)
|
||||||
|
{
|
||||||
|
return $this->redis->get((string) $key) === $this->getUniqueToken($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function evaluate(string $script, string $resource, array $args)
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->redis instanceof \Redis ||
|
||||||
|
$this->redis instanceof \RedisCluster ||
|
||||||
|
$this->redis instanceof RedisProxy ||
|
||||||
|
$this->redis instanceof RedisClusterProxy
|
||||||
|
) {
|
||||||
|
return $this->redis->eval($script, array_merge([$resource], $args), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->redis instanceof \RedisArray) {
|
||||||
|
return $this->redis->_instance($this->redis->_target($resource))->eval($script, array_merge([$resource], $args), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->redis instanceof \Predis\ClientInterface) {
|
||||||
|
return $this->redis->eval(...array_merge([$script, 1, $resource], $args));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidArgumentException(sprintf('"%s()" expects being initialized with a Redis, RedisArray, RedisCluster or Predis\ClientInterface, "%s" given.', __METHOD__, get_debug_type($this->redis)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getUniqueToken(Key $key): string
|
||||||
|
{
|
||||||
|
if (!$key->hasState(__CLASS__)) {
|
||||||
|
$token = base64_encode(random_bytes(32));
|
||||||
|
$key->setState(__CLASS__, $token);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $key->getState(__CLASS__);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -109,4 +109,20 @@ abstract class AbstractStoreTest extends TestCase
|
|||||||
|
|
||||||
$store->delete($key);
|
$store->delete($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testDeleteIsolated()
|
||||||
|
{
|
||||||
|
$store = $this->getStore();
|
||||||
|
|
||||||
|
$key1 = new Key(uniqid(__METHOD__, true));
|
||||||
|
$key2 = new Key(uniqid(__METHOD__, true));
|
||||||
|
|
||||||
|
$store->save($key1);
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
|
||||||
|
$store->delete($key2);
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,10 @@ namespace Symfony\Component\Lock\Tests\Store;
|
|||||||
use PHPUnit\Framework\MockObject\MockObject;
|
use PHPUnit\Framework\MockObject\MockObject;
|
||||||
use Symfony\Component\Lock\BlockingStoreInterface;
|
use Symfony\Component\Lock\BlockingStoreInterface;
|
||||||
use Symfony\Component\Lock\Exception\LockConflictedException;
|
use Symfony\Component\Lock\Exception\LockConflictedException;
|
||||||
|
use Symfony\Component\Lock\Exception\NotSupportedException;
|
||||||
use Symfony\Component\Lock\Key;
|
use Symfony\Component\Lock\Key;
|
||||||
use Symfony\Component\Lock\PersistingStoreInterface;
|
use Symfony\Component\Lock\PersistingStoreInterface;
|
||||||
|
use Symfony\Component\Lock\SharedLockStoreInterface;
|
||||||
use Symfony\Component\Lock\Store\CombinedStore;
|
use Symfony\Component\Lock\Store\CombinedStore;
|
||||||
use Symfony\Component\Lock\Store\RedisStore;
|
use Symfony\Component\Lock\Store\RedisStore;
|
||||||
use Symfony\Component\Lock\Strategy\StrategyInterface;
|
use Symfony\Component\Lock\Strategy\StrategyInterface;
|
||||||
@ -27,6 +29,7 @@ use Symfony\Component\Lock\Strategy\UnanimousStrategy;
|
|||||||
class CombinedStoreTest extends AbstractStoreTest
|
class CombinedStoreTest extends AbstractStoreTest
|
||||||
{
|
{
|
||||||
use ExpiringStoreTestTrait;
|
use ExpiringStoreTestTrait;
|
||||||
|
use SharedLockStoreTestTrait;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
@ -351,4 +354,30 @@ class CombinedStoreTest extends AbstractStoreTest
|
|||||||
|
|
||||||
$this->store->delete($key);
|
$this->store->delete($key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSaveReadWithIncompatibleStores()
|
||||||
|
{
|
||||||
|
$key = new Key(uniqid(__METHOD__, true));
|
||||||
|
|
||||||
|
$badStore = $this->createMock(PersistingStoreInterface::class);
|
||||||
|
|
||||||
|
$store = new CombinedStore([$badStore], new UnanimousStrategy());
|
||||||
|
$this->expectException(NotSupportedException::class);
|
||||||
|
|
||||||
|
$store->saveRead($key);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSaveReadWithCompatibleStore()
|
||||||
|
{
|
||||||
|
$key = new Key(uniqid(__METHOD__, true));
|
||||||
|
|
||||||
|
$goodStore = $this->createMock(SharedLockStoreInterface::class);
|
||||||
|
$goodStore->expects($this->once())
|
||||||
|
->method('saveRead')
|
||||||
|
->with($key);
|
||||||
|
|
||||||
|
$store = new CombinedStore([$goodStore], new UnanimousStrategy());
|
||||||
|
|
||||||
|
$store->saveRead($key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@ use Symfony\Component\Lock\Store\FlockStore;
|
|||||||
class FlockStoreTest extends AbstractStoreTest
|
class FlockStoreTest extends AbstractStoreTest
|
||||||
{
|
{
|
||||||
use BlockingStoreTestTrait;
|
use BlockingStoreTestTrait;
|
||||||
|
use SharedLockStoreTestTrait;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* {@inheritdoc}
|
* {@inheritdoc}
|
||||||
|
@ -21,6 +21,8 @@ use Symfony\Component\Lock\Store\RedisStore;
|
|||||||
*/
|
*/
|
||||||
class RedisStoreTest extends AbstractRedisStoreTest
|
class RedisStoreTest extends AbstractRedisStoreTest
|
||||||
{
|
{
|
||||||
|
use SharedLockStoreTestTrait;
|
||||||
|
|
||||||
public static function setUpBeforeClass(): void
|
public static function setUpBeforeClass(): void
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
|
@ -0,0 +1,177 @@
|
|||||||
|
<?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\Tests\Store;
|
||||||
|
|
||||||
|
use Symfony\Component\Lock\Exception\LockConflictedException;
|
||||||
|
use Symfony\Component\Lock\Key;
|
||||||
|
use Symfony\Component\Lock\PersistingStoreInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author Jérémy Derussé <jeremy@derusse.com>
|
||||||
|
*/
|
||||||
|
trait SharedLockStoreTestTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @see AbstractStoreTest::getStore()
|
||||||
|
*
|
||||||
|
* @return PersistingStoreInterface
|
||||||
|
*/
|
||||||
|
abstract protected function getStore();
|
||||||
|
|
||||||
|
public function testSharedLockReadFirst()
|
||||||
|
{
|
||||||
|
$store = $this->getStore();
|
||||||
|
|
||||||
|
$resource = uniqid(__METHOD__, true);
|
||||||
|
$key1 = new Key($resource);
|
||||||
|
$key2 = new Key($resource);
|
||||||
|
$key3 = new Key($resource);
|
||||||
|
|
||||||
|
$store->saveRead($key1);
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
$this->assertFalse($store->exists($key3));
|
||||||
|
|
||||||
|
// assert we can store multiple keys in read mode
|
||||||
|
$store->saveRead($key2);
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
$this->assertFalse($store->exists($key3));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store->save($key3);
|
||||||
|
$this->fail('The store shouldn\'t save the second key');
|
||||||
|
} catch (LockConflictedException $e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure of previous attempt should not impact the state of current locks
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
$this->assertFalse($store->exists($key3));
|
||||||
|
|
||||||
|
$store->delete($key1);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
$this->assertFalse($store->exists($key3));
|
||||||
|
|
||||||
|
$store->delete($key2);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
$this->assertFalse($store->exists($key3));
|
||||||
|
|
||||||
|
$store->save($key3);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
$this->assertTrue($store->exists($key3));
|
||||||
|
|
||||||
|
$store->delete($key3);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
$this->assertFalse($store->exists($key3));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSharedLockWriteFirst()
|
||||||
|
{
|
||||||
|
$store = $this->getStore();
|
||||||
|
|
||||||
|
$resource = uniqid(__METHOD__, true);
|
||||||
|
$key1 = new Key($resource);
|
||||||
|
$key2 = new Key($resource);
|
||||||
|
|
||||||
|
$store->save($key1);
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store->saveRead($key2);
|
||||||
|
$this->fail('The store shouldn\'t save the second key');
|
||||||
|
} catch (LockConflictedException $e) {
|
||||||
|
}
|
||||||
|
|
||||||
|
// The failure of previous attempt should not impact the state of current locks
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
|
||||||
|
$store->delete($key1);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
|
||||||
|
$store->save($key2);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
|
||||||
|
$store->delete($key2);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSharedLockPromote()
|
||||||
|
{
|
||||||
|
$store = $this->getStore();
|
||||||
|
|
||||||
|
$resource = uniqid(__METHOD__, true);
|
||||||
|
$key1 = new Key($resource);
|
||||||
|
$key2 = new Key($resource);
|
||||||
|
|
||||||
|
$store->saveRead($key1);
|
||||||
|
$store->saveRead($key2);
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store->save($key1);
|
||||||
|
$this->fail('The store shouldn\'t save the second key');
|
||||||
|
} catch (LockConflictedException $e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSharedLockPromoteAllowed()
|
||||||
|
{
|
||||||
|
$store = $this->getStore();
|
||||||
|
|
||||||
|
$resource = uniqid(__METHOD__, true);
|
||||||
|
$key1 = new Key($resource);
|
||||||
|
$key2 = new Key($resource);
|
||||||
|
|
||||||
|
$store->saveRead($key1);
|
||||||
|
$store->save($key1);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$store->saveRead($key2);
|
||||||
|
$this->fail('The store shouldn\'t save the second key');
|
||||||
|
} catch (LockConflictedException $e) {
|
||||||
|
}
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertFalse($store->exists($key2));
|
||||||
|
|
||||||
|
$store->delete($key1);
|
||||||
|
$store->saveRead($key2);
|
||||||
|
$this->assertFalse($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSharedLockDemote()
|
||||||
|
{
|
||||||
|
$store = $this->getStore();
|
||||||
|
|
||||||
|
$resource = uniqid(__METHOD__, true);
|
||||||
|
$key1 = new Key($resource);
|
||||||
|
$key2 = new Key($resource);
|
||||||
|
|
||||||
|
$store->save($key1);
|
||||||
|
$store->saveRead($key1);
|
||||||
|
$store->saveRead($key2);
|
||||||
|
|
||||||
|
$this->assertTrue($store->exists($key1));
|
||||||
|
$this->assertTrue($store->exists($key2));
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user