8000 #27345 Added MongoDbStore · symfony/symfony@0b4abbb · GitHub
[go: up one dir, main page]

Skip to content

Commit 0b4abbb

Browse files
author
Joe Bennett
committed
#27345 Added MongoDbStore
1 parent 05ebca7 commit 0b4abbb

File tree

5 files changed

+397
-2
lines changed

5 files changed

+397
-2
lines changed

phpunit.xml.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<env name="LDAP_PORT" value="3389" />
2020
<env name="REDIS_HOST" value="localhost" />
2121
<env name="MEMCACHED_HOST" value="localhost" />
22+
<env name="MONGODB_HOST" value="localhost" />
2223
</php>
2324

2425
<testsuites>
Lines changed: 329 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,329 @@
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 Exception;
15+
use MongoDB\BSON\UTCDateTime;
16+
use MongoDB\Client;
17+
use MongoDB\Collection;
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\LockConflictedException;
24+
use Symfony\Component\Lock\Exception\LockExpiredException;
25+
use Symfony\Component\Lock\Exception\LockStorageException;
26+
use Symfony\Component\Lock\Exception\NotSupportedException;
27+
use Symfony\Component\Lock\Key;
28+
use Symfony\Component\Lock\StoreInterface;
29+
30+
/**
31+
* MongoDbStore is a StoreInterface implementation using MongoDB as a storage
32+
* engine.
33+
*
34+
* @author Joe Bennett <joe@assimtech.com>
35+
*/
36+
class MongoDbStore implements StoreInterface
37+
{
38+
private $mongo;
39+
private $options;
40+
private $initialTtl;
41+
42+
private $collection;
43+
44+
/**
45+
* @param Client $mongo
46+
* @param array $options See below
47+
* @param float $initialTtl The expiration delay of locks in seconds
48+
*
49+
* @throws InvalidArgumentException if required options are not provided
50+
*
51+
* Options:
52+
* database: The name of the database [required]
53+
* collection: The name of the collection [default: lock]
54+
*
55+
* CAUTION: The locked resouce name is indexed in the _id field of the
56+
* lock collection.
57+
* An indexed field's value in MongoDB can be a maximum of 1024 bytes in
58+
* length inclusive of structural overhead.
59+
*
60+
* @see https://docs.mongodb.com/manual/reference/limits/#Index-Key-Limit
61+
*
62+
* CAUTION: This store relies on all client and server nodes to have
63+
* synchronized clocks for lock expiry to occur at the correct time.
64+
* To ensure locks don't expire prematurely; the lock TTL should be set
65+
* with enough extra time to account for any clock drift between nodes.
66+
* @see self::createTTLIndex()
67+
*
68+
* writeConcern, readConcern and readPreference are not specified by
69+
* MongoDbStore meaning the collection's settings will take effect.
70+
* @see https://docs.mongodb.com/manual/applications/replication/
71+
*/
72+
public function __construct(Client $mongo, array $options, float $initialTtl = 300.0)
73+
{
74+
if (!isset($options['database'])) {
75+
throw new InvalidArgumentException(
76+
'You must provide the "database" option for MongoDBStore'
77+
);
78+
}
79+
80+
$this->mongo = $mongo;
81+
82+
$this->options = array_merge(array(
83+
'collection' => 'lock',
84+
), $options);
85+
86+
$this->initialTtl = $initialTtl;
87+
}
88+
89+
/**
90+
* Create a TTL index to automatically remove expired locks.
91+
*
92+
* This should be called once during database setup.
93+
*
94+
* Alternatively the TTL index can be created manually:
95+
*
96+
* db.lock.ensureIndex(
97+
* { "expires_at": 1 },
98+
* { "expireAfterSeconds": 0 }
99+
* )
100+
*
101+
* Please note, expires_at is based on the application server. If the
102+
* database time differs; a lock could be cleaned up before it has expired.
103+
* Set a positive expireAfterSeconds to account for any time drift between
104+
* application and database server.
105+
*
106+
* A TTL index MUST BE used on MongoDB 2.2+ to automatically clean up expired locks.
107+
*
108+
* @see http://docs.mongodb.org/manual/tutorial/expire-data/
109+
*
110+
* @return string The name of the created index
111+
*
112+
* @throws UnsupportedException if options are not supported by the selected server
113+
* @throws MongoInvalidArgumentException for parameter/option parsing errors
114+
* @throws DriverRuntimeException for other driver errors (e.g. connection errors)
115+
*/
116+
public function createTTLIndex(): string
117+
{
118+
$keys = array(
119+
'expires_at' => 1,
120+
);
121+
122+
$options = array(
123+
'expireAfterSeconds' => 0,
124+
);
125+
126+
return $this->getCollection()->createIndex($keys, $options);
127+
}
128+
129+
/**
130+
* {@inheritdoc}
131+
*
132+
* @throws LockStorageException if failed to save to storage (fallback exception)
133+
* @throws LockExpiredException when save is called on an expired lock
134+
*/
135+
public function save(Key $key)
136+
{
137+
$token = $this->getUniqueToken($key);
138+
$now = microtime(true);
139+
140+
$filter = array(
141+
'_id' => (string) $key,
142+
'$or' => array(
143+
array(
144+
'token' => $token,
145+
),
146+
array(
147+
'expires_at' => array(
148+
'$lte' => $this->createDateTime($now),
149+
),
150+
),
151+
),
152+
);
153+
154+
$update = array(
155+
'$set' => array(
156+
'_id' => (string) $key,
157+
'token' => $token,
158+
'expires_at' => $this->createDateTime($now + $this->initialTtl),
159+
),
160+
);
161+
162+
$options = array(
163+
'upsert' => true,
164+
);
165+
166+
$key->reduceLifetime($this->initialTtl);
167+
168+
try {
169+
$this->getCollection()->updateOne($filter, $update, $options);
170+
} catch (WriteException $e) {
171+
throw new LockConflictedException('Failed to acquire lock', 0, $e);
172+
} catch (Exception $e) {
173+
throw new LockStorageException($e->getMessage(), 0, $e);
174+
}
175+
176+
if ($key->isExpired()) {
177+
throw new LockExpiredException(sprintf('Failed to store the "%s" lock.', $key));
178+
}
179+
}
180+
181+
/**
182+
* {@inheritdoc}
183+
*/
184+
public function waitAndSave(Key $key)
185+
{
186+
throw new NotSupportedException(sprintf(
187+
'The store "%s" does not supports blocking locks.',
188+
__CLASS__
189+
));
190+
}
191+
192+
/**
193+
* {@inheritdoc}
194+
*
195+
* @throws LockStorageException
196+
* @throws LockExpiredException
197+
*/
198+
public function putOffExpiration(Key $key, $ttl)
199+
{
200+
$now = microtime(true);
201+
202+
$filter = array(
203+
'_id' => (string) $key,
204+
'token' => $this->getUniqueToken($key),
205+
'expires_at' => array(
206+
'$gte' => $this->createDateTime($now),
207+
),
208+
);
209+
210+
$update = array(
211+
'$set' => array(
212+
'_id' => (string) $key,
213+
'expires_at' => $this->createDateTime($now + $ttl),
214+
),
215+
);
216+
217+
$options = array(
218+
'upsert' => true,
219+
);
220+
221+
$key->reduceLifetime($ttl);
222+
223+
try {
224+
$this->getCollection()->updateOne($filter, $update, $options);
225+
} catch (WriteException $e) {
226+
$writeErrors = $e->getWriteResult()->getWriteErrors();
227+
if (1 === \count($writeErrors)) {
228+
$code = $writeErrors[0]->getCode();
229+
} else {
230+
$code = $e->getCode();
231+
}
232+
// Mongo error E11000 - DuplicateKey
233+
if (11000 === $code) {
234+
throw new LockConflictedException('Failed to put off the expiration of the lock', 0, $e);
235+
}
236+
throw new LockStorageException($e->getMessage(), 0, $e);
237+
} catch (Exception $e) {
238+
throw new LockStorageException($e->getMessage(), 0, $e);
239+
}
240+
241+
if ($key->isExpired()) {
242+
throw new LockExpiredException(sprintf(
243+
'Failed to put off the expiration of the "%s" lock within the specified time.',
244+
$key
245+
));
246+
}
247+
}
248+
249+
/**
250+
* {@inheritdoc}
251+
*/
252+
public function delete(Key $key)
253+
{
254+
$filter = array(
255+
'_id' => (string) $key,
256+
'token' => $this->getUniqueToken($key),
257+
);
258+
259+
$options = array();
260+
261+
$this->getCollection()->deleteOne($filter, $options);
262+
}
263+
264+
/**
265+
* {@inheritdoc}
266+
*/
267+
public function exists(Key $key)
268+
{
269+
$filter = array(
270+
'_id' => (string) $key,
271+
'token' => $this->getUniqueToken($key),
272+
'expires_at' => array(
273+
'$gte' => $this->createDateTime(),
274+
),
275+
);
276+
277+
$doc = $this->getCollection()->findOne($filter);
278+
279+
return null !== $doc;
280+
}
281+
282+
/**
283+
* @return Collection
284+
*/
285+
private function getCollection(): Collection
286+
{
287+
if (null === $this->collection) {
288+
$this->collection = $this->mongo->selectCollection(
289+
$this->options['database'],
290+
$this->options['collection']
291+
);
292+
}
293+
294+
return $this->collection;
295+
}
296+
297+
/**
298+
* @param float $seconds Seconds since 1970-01-01T00:00:00.000Z supporting millisecond precision. Defaults to now.
299+
*
300+
* @return UTCDateTime
301+
*/
302+
private function createDateTime(float $seconds = null): UTCDateTime
303+
{
304+
if (null === $seconds) {
305+
$seconds = microtime(true);
306+
}
307+
308+
$milliseconds = $seconds * 1000;
309+
310+
return new UTCDateTime($milliseconds);
311+
}
312+
313+
/**
314+
* Retrieves an unique token for the given key namespaced to this store.
315+
*
316+
* @param Key lock state container
317+
*
318+
* @return string token
319+
*/
320+
private function getUniqueToken(Key $key): string
321+
{
322+
if (!$key->hasState(__CLASS__)) {
323+
$token = base64_encode(random_bytes(32));
324+
$key->setState(__CLASS__, $token);
325+
}
326+
327+
return $key->getState(__CLASS__);
328+
}
329+
}

0 commit comments

Comments
 (0)
0