PageRenderTime 44ms CodeModel.GetById 16ms RepoModel.GetById 0ms app.codeStats 0ms

/src/Symfony/Component/Lock/Store/MongoDbStore.php

https://github.com/FabienD/symfony
PHP | 361 lines | 201 code | 36 blank | 124 comment | 20 complexity | 59e33ff6f702a23c128a55a1dd553bab MD5 | raw file
  1. <?php
  2. /*
  3. * This file is part of the Symfony package.
  4. *
  5. * (c) Fabien Potencier <fabien@symfony.com>
  6. *
  7. * For the full copyright and license information, please view the LICENSE
  8. * file that was distributed with this source code.
  9. */
  10. namespace Symfony\Component\Lock\Store;
  11. use MongoDB\BSON\UTCDateTime;
  12. use MongoDB\Client;
  13. use MongoDB\Collection;
  14. use MongoDB\Driver\Exception\WriteException;
  15. use MongoDB\Driver\ReadPreference;
  16. use MongoDB\Exception\DriverRuntimeException;
  17. use MongoDB\Exception\InvalidArgumentException as MongoInvalidArgumentException;
  18. use MongoDB\Exception\UnsupportedException;
  19. use Symfony\Component\Lock\Exception\InvalidArgumentException;
  20. use Symfony\Component\Lock\Exception\InvalidTtlException;
  21. use Symfony\Component\Lock\Exception\LockAcquiringException;
  22. use Symfony\Component\Lock\Exception\LockConflictedException;
  23. use Symfony\Component\Lock\Exception\LockExpiredException;
  24. use Symfony\Component\Lock\Exception\LockStorageException;
  25. use Symfony\Component\Lock\Key;
  26. use Symfony\Component\Lock\PersistingStoreInterface;
  27. /**
  28. * MongoDbStore is a StoreInterface implementation using MongoDB as a storage
  29. * engine. Support for MongoDB server >=2.2 due to use of TTL indexes.
  30. *
  31. * CAUTION: TTL Indexes are used so this store relies on all client and server
  32. * nodes to have synchronized clocks for lock expiry to occur at the correct
  33. * time. To ensure locks don't expire prematurely; the TTLs should be set with
  34. * enough extra time to account for any clock drift between nodes.
  35. *
  36. * CAUTION: The locked resource name is indexed in the _id field of the lock
  37. * collection. An indexed field's value in MongoDB can be a maximum of 1024
  38. * bytes in length inclusive of structural overhead.
  39. *
  40. * @see https://docs.mongodb.com/manual/reference/limits/#Index-Key-Limit
  41. *
  42. * @author Joe Bennett <joe@assimtech.com>
  43. */
  44. class MongoDbStore implements PersistingStoreInterface
  45. {
  46. use ExpiringStoreTrait;
  47. private Collection $collection;
  48. private Client $client;
  49. private string $uri;
  50. private array $options;
  51. private float $initialTtl;
  52. /**
  53. * @param Collection|Client|string $mongo An instance of a Collection or Client or URI @see https://docs.mongodb.com/manual/reference/connection-string/
  54. * @param array $options See below
  55. * @param float $initialTtl The expiration delay of locks in seconds
  56. *
  57. * @throws InvalidArgumentException If required options are not provided
  58. * @throws InvalidTtlException When the initial ttl is not valid
  59. *
  60. * Options:
  61. * gcProbablity: Should a TTL Index be created expressed as a probability from 0.0 to 1.0 [default: 0.001]
  62. * database: The name of the database [required when $mongo is a Client]
  63. * collection: The name of the collection [required when $mongo is a Client]
  64. * uriOptions: Array of uri options. [used when $mongo is a URI]
  65. * driverOptions: Array of driver options. [used when $mongo is a URI]
  66. *
  67. * When using a URI string:
  68. * The database is determined from the uri's path, otherwise the "database" option is used. To specify an alternate authentication database; "authSource" uriOption or querystring parameter must be used.
  69. * The collection is determined from the uri's "collection" querystring parameter, otherwise the "collection" option is used.
  70. *
  71. * For example: mongodb://myuser:mypass@myhost/mydatabase?collection=mycollection
  72. *
  73. * @see https://docs.mongodb.com/php-library/current/reference/method/MongoDBClient__construct/
  74. *
  75. * If gcProbablity is set to a value greater than 0.0 there is a chance
  76. * this store will attempt to create a TTL index on self::save().
  77. * If you prefer to create your TTL Index manually you can set gcProbablity
  78. * to 0.0 and optionally leverage
  79. * self::createTtlIndex(int $expireAfterSeconds = 0).
  80. *
  81. * writeConcern and readConcern are not specified by MongoDbStore meaning the connection's settings will take effect.
  82. * readPreference is primary for all queries.
  83. * @see https://docs.mongodb.com/manual/applications/replication/
  84. */
  85. public function __construct(Collection|Client|string $mongo, array $options = [], float $initialTtl = 300.0)
  86. {
  87. $this->options = array_merge([
  88. 'gcProbablity' => 0.001,
  89. 'database' => null,
  90. 'collection' => null,
  91. 'uriOptions' => [],
  92. 'driverOptions' => [],
  93. ], $options);
  94. $this->initialTtl = $initialTtl;
  95. if ($mongo instanceof Collection) {
  96. $this->collection = $mongo;
  97. } elseif ($mongo instanceof Client) {
  98. $this->client = $mongo;
  99. } else {
  100. $this->uri = $this->skimUri($mongo);
  101. }
  102. if (!($mongo instanceof Collection)) {
  103. if (null === $this->options['database']) {
  104. throw new InvalidArgumentException(sprintf('"%s()" requires the "database" in the URI path or option.', __METHOD__));
  105. }
  106. if (null === $this->options['collection']) {
  107. throw new InvalidArgumentException(sprintf('"%s()" requires the "collection" in the URI querystring or option.', __METHOD__));
  108. }
  109. }
  110. if ($this->options['gcProbablity'] < 0.0 || $this->options['gcProbablity'] > 1.0) {
  111. throw new InvalidArgumentException(sprintf('"%s()" gcProbablity must be a float from 0.0 to 1.0, "%f" given.', __METHOD__, $this->options['gcProbablity']));
  112. }
  113. if ($this->initialTtl <= 0) {
  114. throw new InvalidTtlException(sprintf('"%s()" expects a strictly positive TTL, got "%d".', __METHOD__, $this->initialTtl));
  115. }
  116. }
  117. /**
  118. * Extract default database and collection from given connection URI and remove collection querystring.
  119. *
  120. * Non-standard parameters are removed from the URI to improve libmongoc's re-use of connections.
  121. *
  122. * @see https://www.php.net/manual/en/mongodb.connection-handling.php
  123. */
  124. private function skimUri(string $uri): string
  125. {
  126. if (false === $parsedUrl = parse_url($uri)) {
  127. throw new InvalidArgumentException(sprintf('The given MongoDB Connection URI "%s" is invalid.', $uri));
  128. }
  129. $pathDb = ltrim($parsedUrl['path'] ?? '', '/') ?: null;
  130. if (null !== $pathDb) {
  131. $this->options['database'] = $pathDb;
  132. }
  133. $matches = [];
  134. if (preg_match('/^(.*[\?&])collection=([^&#]*)&?(([^#]*).*)$/', $uri, $matches)) {
  135. $prefix = $matches[1];
  136. $this->options['collection'] = $matches[2];
  137. if (empty($matches[4])) {
  138. $prefix = substr($prefix, 0, -1);
  139. }
  140. $uri = $prefix.$matches[3];
  141. }
  142. return $uri;
  143. }
  144. /**
  145. * Creates a TTL index to automatically remove expired locks.
  146. *
  147. * If the gcProbablity option is set higher than 0.0 (defaults to 0.001);
  148. * there is a chance this will be called on self::save().
  149. *
  150. * Otherwise; this should be called once manually during database setup.
  151. *
  152. * Alternatively the TTL index can be created manually on the database:
  153. *
  154. * db.lock.createIndex(
  155. * { "expires_at": 1 },
  156. * { "expireAfterSeconds": 0 }
  157. * )
  158. *
  159. * Please note, expires_at is based on the application server. If the
  160. * database time differs; a lock could be cleaned up before it has expired.
  161. * To ensure locks don't expire prematurely; the lock TTL should be set
  162. * with enough extra time to account for any clock drift between nodes.
  163. *
  164. * A TTL index MUST BE used to automatically clean up expired locks.
  165. *
  166. * @see http://docs.mongodb.org/manual/tutorial/expire-data/
  167. *
  168. * @throws UnsupportedException if options are not supported by the selected server
  169. * @throws MongoInvalidArgumentException for parameter/option parsing errors
  170. * @throws DriverRuntimeException for other driver errors (e.g. connection errors)
  171. */
  172. public function createTtlIndex(int $expireAfterSeconds = 0)
  173. {
  174. $this->getCollection()->createIndex(
  175. [ // key
  176. 'expires_at' => 1,
  177. ],
  178. [ // options
  179. 'expireAfterSeconds' => $expireAfterSeconds,
  180. ]
  181. );
  182. }
  183. /**
  184. * {@inheritdoc}
  185. *
  186. * @throws LockExpiredException when save is called on an expired lock
  187. */
  188. public function save(Key $key)
  189. {
  190. $key->reduceLifetime($this->initialTtl);
  191. try {
  192. $this->upsert($key, $this->initialTtl);
  193. } catch (WriteException $e) {
  194. if ($this->isDuplicateKeyException($e)) {
  195. throw new LockConflictedException('Lock was acquired by someone else.', 0, $e);
  196. }
  197. throw new LockAcquiringException('Failed to acquire lock.', 0, $e);
  198. }
  199. if ($this->options['gcProbablity'] > 0.0 && (1.0 === $this->options['gcProbablity'] || (random_int(0, \PHP_INT_MAX) / \PHP_INT_MAX) <= $this->options['gcProbablity'])) {
  200. $this->createTtlIndex();
  201. }
  202. $this->checkNotExpired($key);
  203. }
  204. /**
  205. * {@inheritdoc}
  206. *
  207. * @throws LockStorageException
  208. * @throws LockExpiredException
  209. */
  210. public function putOffExpiration(Key $key, float $ttl)
  211. {
  212. $key->reduceLifetime($ttl);
  213. try {
  214. $this->upsert($key, $ttl);
  215. } catch (WriteException $e) {
  216. if ($this->isDuplicateKeyException($e)) {
  217. throw new LockConflictedException('Failed to put off the expiration of the lock.', 0, $e);
  218. }
  219. throw new LockStorageException($e->getMessage(), 0, $e);
  220. }
  221. $this->checkNotExpired($key);
  222. }
  223. /**
  224. * {@inheritdoc}
  225. */
  226. public function delete(Key $key)
  227. {
  228. $this->getCollection()->deleteOne([ // filter
  229. '_id' => (string) $key,
  230. 'token' => $this->getUniqueToken($key),
  231. ]);
  232. }
  233. /**
  234. * {@inheritdoc}
  235. */
  236. public function exists(Key $key): bool
  237. {
  238. return null !== $this->getCollection()->findOne([ // filter
  239. '_id' => (string) $key,
  240. 'token' => $this->getUniqueToken($key),
  241. 'expires_at' => [
  242. '$gt' => $this->createMongoDateTime(microtime(true)),
  243. ],
  244. ], [
  245. 'readPreference' => new ReadPreference(\defined(ReadPreference::PRIMARY) ? ReadPreference::PRIMARY : ReadPreference::RP_PRIMARY),
  246. ]);
  247. }
  248. /**
  249. * Update or Insert a Key.
  250. *
  251. * @param float $ttl Expiry in seconds from now
  252. */
  253. private function upsert(Key $key, float $ttl)
  254. {
  255. $now = microtime(true);
  256. $token = $this->getUniqueToken($key);
  257. $this->getCollection()->updateOne(
  258. [ // filter
  259. '_id' => (string) $key,
  260. '$or' => [
  261. [
  262. 'token' => $token,
  263. ],
  264. [
  265. 'expires_at' => [
  266. '$lte' => $this->createMongoDateTime($now),
  267. ],
  268. ],
  269. ],
  270. ],
  271. [ // update
  272. '$set' => [
  273. '_id' => (string) $key,
  274. 'token' => $token,
  275. 'expires_at' => $this->createMongoDateTime($now + $ttl),
  276. ],
  277. ],
  278. [ // options
  279. 'upsert' => true,
  280. ]
  281. );
  282. }
  283. private function isDuplicateKeyException(WriteException $e): bool
  284. {
  285. $code = $e->getCode();
  286. $writeErrors = $e->getWriteResult()->getWriteErrors();
  287. if (1 === \count($writeErrors)) {
  288. $code = $writeErrors[0]->getCode();
  289. }
  290. // Mongo error E11000 - DuplicateKey
  291. return 11000 === $code;
  292. }
  293. private function getCollection(): Collection
  294. {
  295. if (isset($this->collection)) {
  296. return $this->collection;
  297. }
  298. $this->client ??= new Client($this->uri, $this->options['uriOptions'], $this->options['driverOptions']);
  299. $this->collection = $this->client->selectCollection(
  300. $this->options['database'],
  301. $this->options['collection']
  302. );
  303. return $this->collection;
  304. }
  305. /**
  306. * @param float $seconds Seconds since 1970-01-01T00:00:00.000Z supporting millisecond precision. Defaults to now.
  307. */
  308. private function createMongoDateTime(float $seconds): UTCDateTime
  309. {
  310. return new UTCDateTime($seconds * 1000);
  311. }
  312. /**
  313. * Retrieves an unique token for the given key namespaced to this store.
  314. *
  315. * @param Key lock state container
  316. */
  317. private function getUniqueToken(Key $key): string
  318. {
  319. if (!$key->hasState(__CLASS__)) {
  320. $token = base64_encode(random_bytes(32));
  321. $key->setState(__CLASS__, $token);
  322. }
  323. return $key->getState(__CLASS__);
  324. }
  325. }