Skip to main content
Drupal API
User account menu
  • Log in

Breadcrumb

  1. Drupal Core 11.1.x
  2. MongoDbStore.php

class MongoDbStore

MongoDbStore is a StoreInterface implementation using MongoDB as a storage engine. Support for MongoDB server >=2.2 due to use of TTL indexes.

CAUTION: TTL Indexes are used so 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 TTLs should be set with enough extra time to account for any clock drift between nodes.

CAUTION: The locked resource name is indexed in the _id field of the lock collection. An indexed field's value in MongoDB can be a maximum of 1024 bytes in length inclusive of structural overhead.

@author Joe Bennett <joe@assimtech.com> @author Jérôme Tamarelle <jerome@tamarelle.net>

Hierarchy

  • class \Symfony\Component\Lock\Store\MongoDbStore implements \Symfony\Component\Lock\PersistingStoreInterface uses \Symfony\Component\Lock\Store\ExpiringStoreTrait

Expanded class hierarchy of MongoDbStore

See also

https://docs.mongodb.com/manual/reference/limits/#Index-Key-Limit

File

vendor/symfony/lock/Store/MongoDbStore.php, line 53

Namespace

Symfony\Component\Lock\Store
View source
class MongoDbStore implements PersistingStoreInterface {
    use ExpiringStoreTrait;
    private Manager $manager;
    private string $namespace;
    private string $uri;
    private array $options;
    
    /**
     * @param Collection|Client|Manager|string $mongo      An instance of a Collection or Client or URI @see https://docs.mongodb.com/manual/reference/connection-string/
     * @param array                            $options    See below
     * @param float                            $initialTtl The expiration delay of locks in seconds
     *
     * @throws InvalidArgumentException If required options are not provided
     * @throws InvalidTtlException      When the initial ttl is not valid
     *
     * Options:
     *      gcProbability: Should a TTL Index be created expressed as a probability from 0.0 to 1.0 [default: 0.001]
     *      database:      The name of the database [required when $mongo is a Client]
     *      collection:    The name of the collection [required when $mongo is a Client]
     *      uriOptions:    Array of uri options. [used when $mongo is a URI]
     *      driverOptions: Array of driver options. [used when $mongo is a URI]
     *
     * When using a URI string:
     *      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.
     *      The collection is determined from the uri's "collection" querystring parameter, otherwise the "collection" option is used.
     *
     * For example: mongodb://myuser:mypass@myhost/mydatabase?collection=mycollection
     *
     * @see https://docs.mongodb.com/php-library/current/reference/method/MongoDBClient__construct/
     *
     * If gcProbability is set to a value greater than 0.0 there is a chance
     * this store will attempt to create a TTL index on self::save().
     * If you prefer to create your TTL Index manually you can set gcProbability
     * to 0.0 and optionally leverage
     * self::createTtlIndex(int $expireAfterSeconds = 0).
     *
     * writeConcern and readConcern are not specified by MongoDbStore meaning the connection's settings will take effect.
     * readPreference is primary for all queries.
     * @see https://docs.mongodb.com/manual/applications/replication/
     */
    public function __construct(Collection|Database|Client|Manager|string $mongo, array $options = [], float $initialTtl = 300.0) {
        $this->options = array_merge([
            'gcProbability' => 0.001,
            'database' => null,
            'collection' => null,
            'uriOptions' => [],
            'driverOptions' => [],
        ], $options);
        if ($mongo instanceof Collection) {
            $this->options['database'] ??= $mongo->getDatabaseName();
            $this->options['collection'] ??= $mongo->getCollectionName();
            $this->manager = $mongo->getManager();
        }
        elseif ($mongo instanceof Database) {
            $this->options['database'] ??= $mongo->getDatabaseName();
            $this->manager = $mongo->getManager();
        }
        elseif ($mongo instanceof Client) {
            $this->manager = $mongo->getManager();
        }
        elseif ($mongo instanceof Manager) {
            $this->manager = $mongo;
        }
        else {
            $this->uri = $this->skimUri($mongo);
        }
        if (null === $this->options['database']) {
            throw new InvalidArgumentException(\sprintf('"%s()" requires the "database" in the URI path or option.', __METHOD__));
        }
        if (null === $this->options['collection']) {
            throw new InvalidArgumentException(\sprintf('"%s()" requires the "collection" in the URI querystring or option.', __METHOD__));
        }
        $this->namespace = $this->options['database'] . '.' . $this->options['collection'];
        if ($this->options['gcProbability'] < 0.0 || $this->options['gcProbability'] > 1.0) {
            throw new InvalidArgumentException(\sprintf('"%s()" gcProbability must be a float from 0.0 to 1.0, "%f" given.', __METHOD__, $this->options['gcProbability']));
        }
        if ($this->initialTtl <= 0) {
            throw new InvalidTtlException(\sprintf('"%s()" expects a strictly positive TTL, got "%d".', __METHOD__, $this->initialTtl));
        }
    }
    
    /**
     * Extract default database and collection from given connection URI and remove collection querystring.
     *
     * Non-standard parameters are removed from the URI to improve libmongoc's re-use of connections.
     *
     * @see https://www.php.net/manual/en/mongodb.connection-handling.php
     */
    private function skimUri(string $uri) : string {
        if (!str_starts_with($uri, 'mongodb://') && !str_starts_with($uri, 'mongodb+srv://')) {
            throw new InvalidArgumentException(\sprintf('The given MongoDB Connection URI "%s" is invalid. Expecting "mongodb://" or "mongodb+srv://".', $uri));
        }
        if (false === ($params = parse_url($uri))) {
            throw new InvalidArgumentException(\sprintf('The given MongoDB Connection URI "%s" is invalid.', $uri));
        }
        $pathDb = ltrim($params['path'] ?? '', '/') ?: null;
        if (null !== $pathDb) {
            $this->options['database'] = $pathDb;
        }
        $matches = [];
        if (preg_match('/^(.*[\\?&])collection=([^&#]*)&?(([^#]*).*)$/', $uri, $matches)) {
            $prefix = $matches[1];
            $this->options['collection'] = $matches[2];
            if (empty($matches[4])) {
                $prefix = substr($prefix, 0, -1);
            }
            $uri = $prefix . $matches[3];
        }
        return $uri;
    }
    
    /**
     * Creates a TTL index to automatically remove expired locks.
     *
     * If the gcProbability option is set higher than 0.0 (defaults to 0.001);
     * there is a chance this will be called on self::save().
     *
     * Otherwise; this should be called once manually during database setup.
     *
     * Alternatively the TTL index can be created manually on the database:
     *
     *  db.lock.createIndex(
     *      { "expires_at": 1 },
     *      { "expireAfterSeconds": 0 }
     *  )
     *
     * Please note, expires_at is based on the application server. If the
     * database time differs; a lock could be cleaned up before it has expired.
     * To ensure locks don't expire prematurely; the lock TTL should be set
     * with enough extra time to account for any clock drift between nodes.
     *
     * A TTL index MUST BE used to automatically clean up expired locks.
     *
     * @see http://docs.mongodb.org/manual/tutorial/expire-data/
     *
     * @throws UnsupportedException          if options are not supported by the selected server
     * @throws MongoInvalidArgumentException for parameter/option parsing errors
     * @throws DriverRuntimeException        for other driver errors (e.g. connection errors)
     */
    public function createTtlIndex(int $expireAfterSeconds = 0) : void {
        $server = $this->getManager()
            ->selectServer();
        $server->executeCommand($this->options['database'], new Command([
            'createIndexes' => $this->options['collection'],
            'indexes' => [
                [
                    'key' => [
                        'expires_at' => 1,
                    ],
                    'name' => 'expires_at_1',
                    'expireAfterSeconds' => $expireAfterSeconds,
                ],
            ],
        ]));
    }
    
    /**
     * @throws LockExpiredException when save is called on an expired lock
     */
    public function save(Key $key) : void {
        $key->reduceLifetime($this->initialTtl);
        try {
            $this->upsert($key, $this->initialTtl);
        } catch (BulkWriteException $e) {
            if ($this->isDuplicateKeyException($e)) {
                throw new LockConflictedException('Lock was acquired by someone else.', 0, $e);
            }
            throw new LockAcquiringException('Failed to acquire lock.', 0, $e);
        }
        if ($this->options['gcProbability'] > 0.0 && (1.0 === $this->options['gcProbability'] || random_int(0, \PHP_INT_MAX) / \PHP_INT_MAX <= $this->options['gcProbability'])) {
            $this->createTtlIndex();
        }
        $this->checkNotExpired($key);
    }
    
    /**
     * @throws LockStorageException
     * @throws LockExpiredException
     */
    public function putOffExpiration(Key $key, float $ttl) : void {
        $key->reduceLifetime($ttl);
        try {
            $this->upsert($key, $ttl);
        } catch (BulkWriteException $e) {
            if ($this->isDuplicateKeyException($e)) {
                throw new LockConflictedException('Failed to put off the expiration of the lock.', 0, $e);
            }
            throw new LockStorageException($e->getMessage(), 0, $e);
        }
        $this->checkNotExpired($key);
    }
    public function delete(Key $key) : void {
        $write = new BulkWrite();
        $write->delete([
            '_id' => (string) $key,
            'token' => $this->getUniqueToken($key),
        ], [
            'limit' => 1,
        ]);
        $this->getManager()
            ->executeBulkWrite($this->namespace, $write);
    }
    public function exists(Key $key) : bool {
        $cursor = $this->manager
            ->executeQuery($this->namespace, new Query([
            '_id' => (string) $key,
            'token' => $this->getUniqueToken($key),
            'expires_at' => [
                '$gt' => $this->createMongoDateTime(microtime(true)),
            ],
        ], [
            'limit' => 1,
            'projection' => [
                '_id' => 1,
            ],
        ]));
        return [] !== $cursor->toArray();
    }
    
    /**
     * Update or Insert a Key.
     *
     * @param float $ttl Expiry in seconds from now
     */
    private function upsert(Key $key, float $ttl) : void {
        $now = microtime(true);
        $token = $this->getUniqueToken($key);
        $write = new BulkWrite();
        $write->update([
            '_id' => (string) $key,
            '$or' => [
                [
                    'token' => $token,
                ],
                [
                    'expires_at' => [
                        '$lte' => $this->createMongoDateTime($now),
                    ],
                ],
            ],
        ], [
            '$set' => [
                '_id' => (string) $key,
                'token' => $token,
                'expires_at' => $this->createMongoDateTime($now + $ttl),
            ],
        ], [
            'upsert' => true,
        ]);
        $this->getManager()
            ->executeBulkWrite($this->namespace, $write);
    }
    private function isDuplicateKeyException(BulkWriteException $e) : bool {
        $code = $e->getCode();
        $writeErrors = $e->getWriteResult()
            ->getWriteErrors();
        if (1 === \count($writeErrors)) {
            $code = $writeErrors[0]->getCode();
        }
        // Mongo error E11000 - DuplicateKey
        return 11000 === $code;
    }
    private function getManager() : Manager {
        return $this->manager ??= new Manager($this->uri, $this->options['uriOptions'], $this->options['driverOptions']);
    }
    
    /**
     * @param float $seconds Seconds since 1970-01-01T00:00:00.000Z supporting millisecond precision. Defaults to now.
     */
    private function createMongoDateTime(float $seconds) : UTCDateTime {
        return new UTCDateTime((int) ($seconds * 1000));
    }
    
    /**
     * Retrieves a unique token for the given key namespaced to this store.
     *
     * @param Key $key lock state container
     */
    private function getUniqueToken(Key $key) : string {
        if (!$key->hasState(__CLASS__)) {
            $token = base64_encode(random_bytes(32));
            $key->setState(__CLASS__, $token);
        }
        return $key->getState(__CLASS__);
    }

}

Members

Title Sort descending Modifiers Object type Summary Overriden Title
ExpiringStoreTrait::checkNotExpired private function
MongoDbStore::$manager private property
MongoDbStore::$namespace private property
MongoDbStore::$options private property
MongoDbStore::$uri private property
MongoDbStore::createMongoDateTime private function
MongoDbStore::createTtlIndex public function Creates a TTL index to automatically remove expired locks.
MongoDbStore::delete public function Removes a resource from the storage. Overrides PersistingStoreInterface::delete
MongoDbStore::exists public function Returns whether or not the resource exists in the storage. Overrides PersistingStoreInterface::exists
MongoDbStore::getManager private function
MongoDbStore::getUniqueToken private function Retrieves a unique token for the given key namespaced to this store.
MongoDbStore::isDuplicateKeyException private function
MongoDbStore::putOffExpiration public function Overrides PersistingStoreInterface::putOffExpiration
MongoDbStore::save public function Overrides PersistingStoreInterface::save
MongoDbStore::skimUri private function Extract default database and collection from given connection URI and remove collection querystring.
MongoDbStore::upsert private function Update or Insert a Key.
MongoDbStore::__construct public function

API Navigation

  • Drupal Core 11.1.x
  • Topics
  • Classes
  • Functions
  • Constants
  • Globals
  • Files
  • Namespaces
  • Deprecated
  • Services
RSS feed
Powered by Drupal