-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[Lock] Added Symfony/Component/Lock/Store/MongoDbStore #27346
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
Changes from 11 commits
157fcd7
e73f663
3838412
1a660e6
d65fddf
f3cc220
ec8d217
a1a3be7
1b150ba
73bb802
5e86904
8572c21
dbdad36
84c4d15
d577191
2ce68f5
5446b11
a9b85d6
6fdc602
9d80a0d
1ac702a
4ba5b41
58ef873
36c3af4
168f194
abc72f8
e062bf2
f2e23b4
97e5711
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,251 @@ | ||
<?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\InvalidArgumentException; | ||
use Symfony\Component\Lock\Exception\LockConflictedException; | ||
use Symfony\Component\Lock\Exception\LockExpiredException; | ||
use Symfony\Component\Lock\Exception\LockStorageException; | ||
use Symfony\Component\Lock\Exception\NotSupportedException; | ||
use Symfony\Component\Lock\Key; | ||
use Symfony\Component\Lock\StoreInterface; | ||
|
||
/** | ||
* MongoDbStore is a StoreInterface implementation using MongoDB as store engine. | ||
* | ||
* @author Joe Bennett <joe@assimtech.com> | ||
*/ | ||
class MongoDbStore implements StoreInterface | ||
{ | ||
private $mongo; | ||
private $options; | ||
private $collection; | ||
|
||
/** | ||
* @param \MongoDB\Client $mongo | ||
* @param array $options | ||
* | ||
* database: The name of the database [required] | ||
* collection: The name of the collection [default: lock] | ||
* initialTtl: The expiration delay of locks in seconds [default: 300.0] | ||
* | ||
* CAUTION: This store relies on all client and server nodes to have | ||
* synchronized clocks for lock expiry to occur at the correct time. | ||
* To ensure locks don't expire prematurely; the ttl's should be set with enough | ||
* extra time to account for any clock drift between nodes. | ||
* | ||
* A TTL index MUST BE used on MongoDB 2.2+ to automatically clean up expired locks. | ||
* | ||
* db.lock.ensureIndex( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I suggest to provide a There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I've added a method to |
||
* { "expires_at": 1 }, | ||
* { "expireAfterSeconds": 0 } | ||
* ) | ||
* | ||
* @see http://docs.mongodb.org/manual/tutorial/expire-data/ | ||
* | ||
* writeConcern, readConcern and readPreference are not specified by MongoDbStore | ||
* meaning the collection's settings will take effect. | ||
* @see https://docs.mongodb.com/manual/applications/replication/ | ||
* | ||
* Please note, the Symfony\Component\Lock\Key's $resource | ||
* must not exceed 1024 bytes including structural overhead. | ||
* @see https://docs.mongodb.com/manual/reference/limits/#Index-Key-Limit | ||
*/ | ||
public function __construct(\MongoDB\Client $mongo, array $options) | ||
{ | ||
if (!isset($options['database'])) { | ||
throw new InvalidArgumentException( | ||
'You must provide the "database" option for MongoDBStore' | ||
); | ||
} | ||
|
||
$this->mongo = $mongo; | ||
|
||
$this->options = array_merge(array( | ||
'collection' => 'lock', | ||
'initialTtl' => 300.0, | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I really wonder if options for lock should be merge with options for database connection? See PdoTrait from cache components. What is the opinion of other contributors? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I originally had I personally think it belongs outside of See also: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Cache/Adapter/PdoAdapter.php |
||
), $options); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function save(Key $key) | ||
{ | ||
$token = $this->getToken($key); | ||
$now = microtime(true); | ||
|
||
$filter = array( | ||
'_id' => (string) $key, | ||
'$or' => array( | ||
array( | ||
'token' => $token, | ||
), | ||
array( | ||
'expires_at' => array( | ||
'$lte' => $this->createDateTime($now), | ||
), | ||
), | ||
), | ||
); | ||
|
||
$update = array( | ||
'$set' => array( | ||
'_id' => (string) $key, | ||
'token' => $token, | ||
'expires_at' => $this->createDateTime($now + $this->options['initialTtl']), | ||
), | ||
); | ||
|
||
$options = array( | ||
'upsert' => true, | ||
); | ||
|
||
$key->reduceLifetime($this->options['initialTtl']); | ||
try { | ||
$this->getCollection()->updateOne($filter, $update, $options); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is it atomic? on every version of mongodb? What's about réplication propagation? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. MongoDB is atomic on a singular statement / document only. Hence the use of a update with filter (1 statement). MongoDB There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. With regards to I have added some documentation regarding this decision to |
||
} catch (\MongoDB\Driver\Exception\WriteException $e) { | ||
throw new LockConflictedException('Failed to acquire lock', 0, $e); | ||
} catch (\Exception $e) { | ||
throw new LockStorageException($e->getMessage(), 0, $e); | ||
} | ||
|
||
if ($key->isExpired()) { | ||
throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key)); | ||
} | ||
} | ||
|
||
public function waitAndSave(Key $key) | ||
{ | ||
throw new NotSupportedException(sprintf( | ||
'The store "%s" does not supports blocking locks.', | ||
__CLASS__ | ||
)); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function putOffExpiration(Key $key, $ttl) | ||
{ | ||
$now = microtime(true); | ||
|
||
$filter = array( | ||
'_id' => (string) $key, | ||
'token' => $this->getToken($key), | ||
'expires_at' => array( | ||
'$gte' => $this->createDateTime($now), | ||
), | ||
); | ||
|
||
$update = array( | ||
'$set' => array( | ||
'_id' => (string) $key, | ||
'expires_at' => $this->createDateTime($now + $ttl), | ||
), | ||
); | ||
|
||
$options = array( | ||
'upsert' => true, | ||
); | ||
|
||
$key->reduceLifetime($ttl); | ||
try { | ||
$this->getCollection()->updateOne($filter, $update, $options); | ||
} catch (\MongoDB\Driver\Exception\WriteException $e) { | ||
throw new LockConflictedException('Failed to put off the expiration of the lock', 0, $e); | ||
} catch (\Exception $e) { | ||
throw new LockStorageException($e->getMessage(), 0, $e); | ||
} | ||
|
||
if ($key->isExpired()) { | ||
throw new LockExpiredException(sprintf( | ||
'Failed to put off the expiration of the "%s" lock within the specified time.', | ||
$key | ||
)); | ||
} | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function delete(Key $key) | ||
{ | ||
$filter = array( | ||
'_id' => (string) $key, | ||
'token' => $this->getToken($key), | ||
); | ||
|
||
$options = array(); | ||
|
||
$this->getCollection()->deleteOne($filter, $options); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function exists(Key $key) | ||
{ | ||
$filter = array( | ||
'_id' => (string) $key, | ||
'token' => $this->getToken($key), | ||
'expires_at' => array( | ||
'$gte' => $this->createDateTime(), | ||
), | ||
); | ||
|
||
$doc = $this->getCollection()->findOne($filter); | ||
|
||
return null !== $doc; | ||
} | ||
|
||
private function getCollection(): \MongoDB\Collection | ||
{ | ||
if (null === $this->collection) { | ||
$this->collection = $this->mongo->selectCollection( | ||
$this->options['database'], | ||
$this->options['collection'] | ||
); | ||
} | ||
|
||
return $this->collection; | ||
} | ||
|
||
/** | ||
* @param float $seconds Seconds since 1970-01-01T00:00:00.000Z supporting millisecond precision. Defaults to now. | ||
* | ||
* @return \MongoDB\BSON\UTCDateTime | ||
*/ | ||
private function createDateTime(float $seconds = null): \MongoDB\BSON\UTCDateTime | ||
{ | ||
if (null === $seconds) { | ||
$seconds = microtime(true); | ||
} | ||
|
||
$milliseconds = $seconds * 1000; | ||
|
||
return new \MongoDB\BSON\UTCDateTime($milliseconds); | ||
} | ||
|
||
/** | ||
* Retrieves an unique token for the given key. | ||
*/ | ||
private function getToken(Key $key): string | ||
{ | ||
if (!$key->hasState(__CLASS__)) { | ||
$token = base64_encode(random_bytes(32)); | ||
$key->setState(__CLASS__, $token); | ||
} | ||
|
||
return $key->getState(__CLASS__); | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,58 @@ | ||
<?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\Store\MongoDbStore; | ||
|
||
/** | ||
* @author Joe Bennett <joe@assimtech.com> | ||
*/ | ||
class MongoDbClientTest extends AbstractStoreTest | ||
{ | ||
use ExpiringStoreTestTrait; | ||
|
||
public static function setupBeforeClass() | ||
{ | ||
try { | ||
if (!class_exists(\MongoDB\Client::class)) { | ||
throw new \RuntimeException('The mongodb/mongodb package is required.'); | ||
} | ||
$client = self::getMongoConnection(); | ||
$client->listDatabases(); | ||
} catch (\Exception $e) { | ||
self::markTestSkipped($e->getMessage()); | ||
} | ||
} | ||
|
||
protected static function getMongoConnection(): \MongoDB\Client | ||
{ | ||
return new \MongoDB\Client('mongodb://'.getenv('MONGODB_HOST')); | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
protected function getClockDelay() | ||
{ | ||
return 250000; | ||
} | ||
|
||
/** | ||
* {@inheritdoc} | ||
*/ | ||
public function getStore() | ||
{ | ||
return new MongoDbStore(self::getMongoConnection(), array( | ||
'database' => 'test', | ||
)); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a way to pass a dns for lazy connection? See PdoTrait from cache components?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A
MongoDB\Client
instance is already lazy. I can turn off my database and still:only if I add something like:
does it throw an exception.