Skip to content

Commit 987548d

Browse files
author
Joe Bennett
committed
#27345 Added MongoDbStore
1 parent c871857 commit 987548d

File tree

6 files changed

+360
-0
lines changed

6 files changed

+360
-0
lines changed

phpunit.xml.dist

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

src/Symfony/Component/Lock/StoreInterface.php

+4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\Component\Lock;
1313

1414
use Symfony\Component\Lock\Exception\LockConflictedException;
15+
use Symfony\Component\Lock\Exception\LockExpiredException;
1516
use Symfony\Component\Lock\Exception\NotSupportedException;
1617

1718
/**
@@ -25,6 +26,7 @@ interface StoreInterface
2526
* Stores the resource if it's not locked by someone else.
2627
*
2728
* @throws LockConflictedException
29+
* @throws LockExpiredException
2830
*/
2931
public function save(Key $key);
3032

@@ -34,6 +36,7 @@ public function save(Key $key);
3436
* If the store does not support this feature it should throw a NotSupportedException.
3537
*
3638
* @throws LockConflictedException
39+
* @throws LockExpiredException
3740
* @throws NotSupportedException
3841
*/
3942
public function waitAndSave(Key $key);
@@ -46,6 +49,7 @@ public function waitAndSave(Key $key);
4649
* @param float $ttl amount of second to keep the lock in the store
4750
*
4851
* @throws LockConflictedException
52+
* @throws LockExpiredException
4953
* @throws NotSupportedException
5054
*/
5155
public function putOffExpiration(Key $key, $ttl);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
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\Tests\Store;
13+
14+
use Symfony\Component\Lock\Store\MongoDbStore;
15+
16+
/**
17+
* @author Joe Bennett <joe@assimtech.com>
18+
*/
19+
class MongoDbStoreTest extends AbstractStoreTest
20+
{
21+
use ExpiringStoreTestTrait;
22+
23+
public static function setupBeforeClass()
24+
{
25+
try {
26+
if (!class_exists(\MongoDB\Client::class)) {
27+
throw new \RuntimeException('The mongodb/mongodb package is required.');
28+
}
29+
$client = self::getMongoConnection();
30+
$client->listDatabases();
31+
} catch (\Exception $e) {
32+
self::markTestSkipped($e->getMessage());
33+
}
34+
}
35+
36+
protected static function getMongoConnection(): \MongoDB\Client
37+
{
38+
return new \MongoDB\Client('mongodb://'.getenv('MONGODB_HOST'));
39+
}
40+
41+
/**
42+
* {@inheritdoc}
43+
*/
44+
protected function getClockDelay()
45+
{
46+
return 250000;
47+
}
48+
49+
/**
50+
* {@inheritdoc}
51+
*/
52+
public function getStore()
53+
{
54+
return new MongoDbStore(self::getMongoConnection(), array(
55+
'database' => 'test',
56+
));
57+
}
58+
59+
public function testCreateIndex()
60+
{
61+
$store = $this->getStore();
62+
63+
$this->assertEquals($store->createTTLIndex(), 'expires_at_1');
64+
}
65+
}

0 commit comments

Comments
 (0)