Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit a6bfa59

Browse files
Joe Bennettfabpot
Joe Bennett
authored andcommitted
[Lock] add mongodb store
1 parent 1827908 commit a6bfa59

File tree

5 files changed

+531
-2
lines changed

5 files changed

+531
-2
lines changed

src/Symfony/Component/Lock/CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@ CHANGELOG
1111
4.4.0
1212
-----
1313

14-
* added InvalidTtlException
14+
* added InvalidTtlException
15+
* added the MongoDbStore supporting MongoDB servers >=2.2
1516
* deprecated `StoreInterface` in favor of `BlockingStoreInterface` and `PersistingStoreInterface`
1617
* `Factory` is deprecated, use `LockFactory` instead
1718
* `StoreFactory::createStore` allows PDO and Zookeeper DSN.
Lines changed: 386 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,386 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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 MongoDB\BSON\UTCDateTime;
15+
use MongoDB\Client;
16+
use MongoDB\Collection;
17+
use MongoDB\Driver\Command;
18+
use MongoDB\Driver\Exception\WriteException;
19+
use MongoDB\Exception\DriverRuntimeException;
20+
use MongoDB\Exception\InvalidArgumentException as MongoInvalidArgumentException;
21+
use MongoDB\Exception\UnsupportedException;
22+
use Symfony\Component\Lock\Exception\InvalidArgumentException;
23+
use Symfony\Component\Lock\Exception\InvalidTtlException;
24+
use Symfony\Component\Lock\Exception\LockAcquiringException;
25+
use Symfony\Component\Lock\Exception\LockConflictedException;
26+
use Symfony\Component\Lock\Exception\LockExpiredException;
27+
use Symfony\Component\Lock\Exception\LockStorageException;
28+
use Symfony\Component\Lock\Exception\NotSupportedException;
29+
use Symfony\Component\Lock\Key;
30+
use Symfony\Component\Lock\StoreInterface;
31+
32+
/**
33+
* MongoDbStore is a StoreInterface implementation using MongoDB as a storage
34+
* engine. Support for MongoDB server >=2.2 due to use of TTL indexes.
35+
*
36+
* CAUTION: TTL Indexes are used so this store relies on all client and server
37+
* nodes to have synchronized clocks for lock expiry to occur at the correct
38+
* time. To ensure locks don't expire prematurely; the TTLs should be set with
39+
* enough extra time to account for any clock drift between nodes.
40+
*
41+
* CAUTION: The locked resource name is indexed in the _id field of the lock
42+
* collection. An indexed field's value in MongoDB can be a maximum of 1024
43+
* bytes in length inclusive of structural overhead.
44+
*
45+
* @see https://docs.mongodb.com/manual/reference/limits/#Index-Key-Limit
46+
*
47+
* @requires extension mongodb
48+
*
49+
* @author Joe Bennett <[email protected]>
50+
*/
51+
class MongoDbStore implements StoreInterface
52+
{
53+
private $collection;
54+
private $client;
55+
private $uri;
56+
private $options;
57+
private $initialTtl;
58+
59+
private $databaseVersion;
60+
61+
use ExpiringStoreTrait;
62+
63+
/**
64+
* @param Collection|Client|string $mongo An instance of a Collection or Client or URI @see https://docs.mongodb.com/manual/reference/connection-string/
65+
* @param array $options See below
66+
* @param float $initialTtl The expiration delay of locks in seconds
67+
*
68+
* @throws InvalidArgumentException If required options are not provided
69+
* @throws InvalidTtlException When the initial ttl is not valid
70+
*
71+
* Options:
72+
* gcProbablity: Should a TTL Index be created expressed as a probability from 0.0 to 1.0 [default: 0.001]
73+
* database: The name of the database [required when $mongo is a Client]
74+
* collection: The name of the collection [required when $mongo is a Client]
75+
* uriOptions: Array of uri options. [used when $mongo is a URI]
76+
* driverOptions: Array of driver options. [used when $mongo is a URI]
77+
*
78+
* When using a URI string:
79+
* the database is determined from the "database" option, otherwise the uri's path is used.
80+
* the collection is determined from the "collection" option, otherwise the uri's "collection" querystring parameter is used.
81+
*
82+
* For example: mongodb://myuser:mypass@myhost/mydatabase?collection=mycollection
83+
*
84+
* @see https://docs.mongodb.com/php-library/current/reference/method/MongoDBClient__construct/
85+
*
86+
* If gcProbablity is set to a value greater than 0.0 there is a chance
87+
* this store will attempt to create a TTL index on self::save().
88+
* If you prefer to create your TTL Index manually you can set gcProbablity
89+
* to 0.0 and optionally leverage
90+
* self::createTtlIndex(int $expireAfterSeconds = 0).
91+
*
92+
* writeConcern, readConcern and readPreference are not specified by
93+
* MongoDbStore meaning the collection's settings will take effect.
94+
* @see https://docs.mongodb.com/manual/applications/replication/
95+
*/
96+
public function __construct($mongo, array $options = [], float $initialTtl = 300.0)
97+
{
98+
$this->options = array_merge([
99+
'gcProbablity' => 0.001,
100+
'database' => null,
101+
'collection' => null,
102+
'uriOptions' => [],
103+
'driverOptions' => [],
104+
], $options);
105+
106+
$this->initialTtl = $initialTtl;
107+
108+
if ($mongo instanceof Collection) {
109+
$this->collection = $mongo;
110+
} elseif ($mongo instanceof Client) {
111+
if (null === $this->options['database']) {
112+
throw new InvalidArgumentException(sprintf('%s() requires the "database" option when constructing with a %s', __METHOD__, Client::class));
113+
}
114+
if (null === $this->options['collection']) {
115+
throw new InvalidArgumentException(sprintf('%s() requires the "collection" option when constructing with a %s', __METHOD__, Client::class));
116+
}
117+
118+
$this->client = $mongo;
119+
} elseif (\is_string($mongo)) {
120+
if (false === $parsedUrl = parse_url($mongo)) {
121+
throw new InvalidArgumentException(sprintf('The given MongoDB Connection URI "%s" is invalid.', $mongo));
122+
}
123+
$query = [];
124+
if (isset($parsedUrl['query'])) {
125+
parse_str($parsedUrl['query'], $query);
126+
}
127+
$this->options['collection'] = $this->options['collection'] ?? $query['collection'] ?? null;
128+
$this->options['database'] = $this->options['database'] ?? ltrim($parsedUrl['path'] ?? '', '/') ?: null;
129+
if (null === $this->options['database']) {
130+
throw new InvalidArgumentException(sprintf('%s() requires the "database" in the uri path or option when constructing with a uri', __METHOD__));
131+
}
132+
if (null === $this->options['collection']) {
133+
throw new InvalidArgumentException(sprintf('%s() requires the "collection" in the uri querystring or option when constructing with a uri', __METHOD__));
134+
}
135+
136+
$this->uri = $mongo;
137+
} else {
138+
throw new InvalidArgumentException(sprintf('%s() requires %s or %s or URI as first argument, %s given.', __METHOD__, Collection::class, Client::class, \is_object($mongo) ? \get_class($mongo) : \gettype($mongo)));
139+
}
140+
141+
if ($this->options['gcProbablity'] < 0.0 || $this->options['gcProbablity'] > 1.0) {
142+
throw new InvalidArgumentException(sprintf('%s() gcProbablity must be a float from 0.0 to 1.0, %f given.', __METHOD__, $this->options['gcProbablity']));
143+
}
144+
145+
if ($this->initialTtl <= 0) {
146+
throw new InvalidTtlException(sprintf('%s() expects a strictly positive TTL. Got %d.', __METHOD__, $this->initialTtl));
147+
}
148+
}
149+
150+
/**
151+
* Create a TTL index to automatically remove expired locks.
152+
*
153+
* If the gcProbablity option is set higher than 0.0 (defaults to 0.001);
154+
* there is a chance this will be called on self::save().
155+
*
156+
* Otherwise; this should be called once manually during database setup.
157+
*
158+
* Alternatively the TTL index can be created manually on the database:
159+
*
160+
* db.lock.ensureIndex(
161+
* { "expires_at": 1 },
162+
* { "expireAfterSeconds": 0 }
163+
* )
164+
*
165+
* Please note, expires_at is based on the application server. If the
166+
* database time differs; a lock could be cleaned up before it has expired.
167+
* To ensure locks don't expire prematurely; the lock TTL should be set
168+
* with enough extra time to account for any clock drift between nodes.
169+
*
170+
* A TTL index MUST BE used to automatically clean up expired locks.
171+
*
172+
* @see http://docs.mongodb.org/manual/tutorial/expire-data/
173+
*
174+
* @throws UnsupportedException if options are not supported by the selected server
175+
* @throws MongoInvalidArgumentException for parameter/option parsing errors
176+
* @throws DriverRuntimeException for other driver errors (e.g. connection errors)
177+
*/
178+
public function createTtlIndex(int $expireAfterSeconds = 0)
179+
{
180+
$this->getCollection()->createIndex(
181+
[ // key
182+
'expires_at' => 1,
183+
],
184+
[ // options
185+
'expireAfterSeconds' => $expireAfterSeconds,
186+
]
187+
);
188+
}
189+
190+
/**
191+
* {@inheritdoc}
192+
*
193+
* @throws LockExpiredException when save is called on an expired lock
194+
*/
195+
public function save(Key $key)
196+
{
197+
$key->reduceLifetime($this->initialTtl);
198+
199+
try {
200+
$this->upsert($key, $this->initialTtl);
201+
} catch (WriteException $e) {
202+
if ($this->isDuplicateKeyException($e)) {
203+
throw new LockConflictedException('Lock was acquired by someone else', 0, $e);
204+
}
205+
throw new LockAcquiringException('Failed to acquire lock', 0, $e);
206+
}
207+
208+
if ($this->options['gcProbablity'] > 0.0
209+
&& (
210+
1.0 === $this->options['gcProbablity']
211+
|| (random_int(0, PHP_INT_MAX) / PHP_INT_MAX) <= $this->options['gcProbablity']
212+
)
213+
) {
214+
$this->createTtlIndex();
215+
}
216+
217+
$this->checkNotExpired($key);
218+
}
219+
220+
/**
221+
* {@inheritdoc}
222+
*/
223+
public function waitAndSave(Key $key)
224+
{
225+
throw new NotSupportedException(sprintf('The store "%s" does not support blocking locks.', __CLASS__));
226+
}
227+
228+
/**
229+
* {@inheritdoc}
230+
*
231+
* @throws LockStorageException
232+
* @throws LockExpiredException
233+
*/
234+
public function putOffExpiration(Key $key, $ttl)
235+
{
236+
$key->reduceLifetime($ttl);
237+
238+
try {
239+
$this->upsert($key, $ttl);
240+
} catch (WriteException $e) {
241+
if ($this->isDuplicateKeyException($e)) {
242+
throw new LockConflictedException('Failed to put off the expiration of the lock', 0, $e);
243+
}
244+
throw new LockStorageException($e->getMessage(), 0, $e);
245+
}
246+
247+
$this->checkNotExpired($key);
248+
}
249+
250+
/**
251+
* {@inheritdoc}
252+
*/
253+
public function delete(Key $key)
254+
{
255+
$this->getCollection()->deleteOne([ // filter
256+
'_id' => (string) $key,
257+
'token' => $this->getUniqueToken($key),
258+
]);
259+
}
260+
261+
/**
262+
* {@inheritdoc}
263+
*/
264+
public function exists(Key $key): bool
265+
{
266+
return null !== $this->getCollection()->findOne([ // filter
267+
'_id' => (string) $key,
268+
'token' => $this->getUniqueToken($key),
269+
'expires_at' => [
270+
'$gt' => $this->createMongoDateTime(microtime(true)),
271+
],
272+
]);
273+
}
274+
275+
/**
276+
* Update or Insert a Key.
277+
*
278+
* @param float $ttl Expiry in seconds from now
279+
*/
280+
private function upsert(Key $key, float $ttl)
281+
{
282+
$now = microtime(true);
283+
$token = $this->getUniqueToken($key);
284+
285+
$this->getCollection()->updateOne(
286+
[ // filter
287+
'_id' => (string) $key,
288+
'$or' => [
289+
[
290+
'token' => $token,
291+
],
292+
[
293+
'expires_at' => [
294+
'$lte' => $this->createMongoDateTime($now),
295+
],
296+
],
297+
],
298+
],
299+
[ // update
300+
'$set' => [
301+
'_id' => (string) $key,
302+
'token' => $token,
303+
'expires_at' => $this->createMongoDateTime($now + $ttl),
304+
],
305+
],
306+
[ // options
307+
'upsert' => true,
308+
]
309+
);
310+
}
311+
312+
private function isDuplicateKeyException(WriteException $e): bool
313+
{
314+
$code = $e->getCode();
315+
316+
$writeErrors = $e->getWriteResult()->getWriteErrors();
317+
if (1 === \count($writeErrors)) {
318+
$code = $writeErrors[0]->getCode();
319+
}
320+
321+
// Mongo error E11000 - DuplicateKey
322+
return 11000 === $code;
323+
}
324+
325+
private function getDatabaseVersion(): string
326+
{
327+
if (null !== $this->databaseVersion) {
328+
return $this->databaseVersion;
329+
}
330+
331+
$command = new Command([
332+
'buildinfo' => 1,
333+
]);
334+
$cursor = $this->getCollection()->getManager()->executeReadCommand(
335+
$this->getCollection()->getDatabaseName(),
336+
$command
337+
);
338+
$buildInfo = $cursor->toArray()[0];
339+
$this->databaseVersion = $buildInfo->version;
340+
341+
return $this->databaseVersion;
342+
}
343+
344+
private function getCollection(): Collection
345+
{
346+
if (null !== $this->collection) {
347+
return $this->collection;
348+
}
349+
350+
if (null === $this->client) {
351+
$this->client = new Client($this->uri, $this->options['uriOptions'], $this->options['driverOptions']);
352+
}
353+
354+
$this->collection = $this->client->selectCollection(
355+
$this->options['database'],
356+
$this->options['collection']
357+
);
358+
359+
return $this->collection;
360+
}
361+
362+
/**
363+
* @param float $seconds Seconds since 1970-01-01T00:00:00.000Z supporting millisecond precision. Defaults to now.
364+
*/
365+
private function createMongoDateTime(float $seconds): UTCDateTime
366+
{
367+
return new UTCDateTime($seconds * 1000);
368+
}
369+
370+
/**
371+
* Retrieves an unique token for the given key namespaced to this store.
372+
*
373+
* @param Key lock state container
374+
*
375+
* @return string token
376+
*/
377+
private function getUniqueToken(Key $key): string
378+
{
379+
if (!$key->hasState(__CLASS__)) {
380+
$token = base64_encode(random_bytes(32));
381+
$key->setState(__CLASS__, $token);
382+
}
383+
384+
return $key->getState(__CLASS__);
385+
}
386+
}

0 commit comments

Comments
 (0)