Skip to content

[Lock] Added Symfony/Component/Lock/Store/MongoDbStore #27346

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 29 commits into from

Conversation

kralos
Copy link

@kralos kralos commented May 23, 2018

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes (requires ext-mongodb and mongodb/mongodb to test)
Fixed tickets #27345
License MIT
Doc PR symfony/symfony-docs#9807

Testing caveat
In order to test this, the test environment needs ext-mongodb and mongodb/mongodb. Travis has ext-mongodb but Appveyor doesn't meaning I can't require-dev mongodb/mongodb. I had a look at Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler since it poses the same issue and neither symfony/symfony/src/Symfony/Component/HttpFoundation/composer.json nor symfony/symfony/composer.json require-dev mongodb/mongodb. They both skip mongo based tests in the ci environments. I have therefore chosen to omit require-dev mongodb/mongodb.

I have both written the test and tested Symfony\Component\Lock\Store\MongoDbStore and it does pass in an environment with ext-mongodb and mongodb/mongodb.

Description
We should support Semaphore Locks with a MongoDB back end to allow those that already use MongoDB as a distributed storage engine.

Symfony already partially supports MongoDB for session storage: Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler

Example

$client = new MongoDb\Client();

$store = new Symfony\Component\Lock\Store\MongoDbStore(
    $client
    array(
        'database' => 'my-app',
    )
);
$lockFactory = new Symfony\Component\Lock\Factory($store);
$lock = $lockFactory->createLock('my-resource');

@kralos
Copy link
Author

kralos commented May 23, 2018

@nicolas-grekas what's the deal with adding ext-mongodb to symfony/symfony's appveyor build environment? Could you help me out?

@nicolas-grekas
Copy link
Member

No need for the ext on appveyor. Just skip tests there. An @requires extension mongodbannotation on the test class should do it.

travis env has ext-mongodb but appveyor doesn't

since MongoDbSessionHandler does the same i've stripped the dev requirement for mongodb/mongodb
@nicolas-grekas nicolas-grekas added this to the next milestone May 23, 2018
@kralos kralos changed the title #27345 Added Symfony/Component/Lock/Store/MongoDbStore [Lock] Added Symfony/Component/Lock/Store/MongoDbStore May 25, 2018
Copy link
Member

@jderusse jderusse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not familiar with MongoDb, I'm OK with the current implementation (with few comments),

BUT I've many concerns about the reliability of MongoDb. It's wellknown for not beeing ACID, whereas Atomicity is a requirements. What's about a MongoDb cluster configured with sharding and replication?

* collection: The name of the collection [default: lock]
* resource_field: The field name for storing the lock id [default: _id] MUST be uniquely indexed if you chage it
* token_field: The field name for storing the lock token [default: token]
* acquired_field: The field name for storing the acquisition timestamp [default: acquired_at]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what is the purpose of this field?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've removed it. I found it useful to debug / see when a lock was acquired. Probably not that useful in operation.

* the locks in the database as described below:
*
* A TTL collections can be used on MongoDB 2.2+ to cleanup expired locks
* automatically. Such an index can for example look like this:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the expiration date is computed by the application, then you should warn the developer that a clock drift between the app server and the database server may delete the lock to early

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a comment to the phpdoc and symfony-docs. I also recommended setting the expireAfterSeconds to a value greater than 0. The actual expiry is already handled by the application (not the DBMS's clocks). I have updated my example to 60 seconds.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMHO this safety window should be a constructor parameter. Because the same issue exist on the save method when 2 clients are not time synchronized

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Currently the recommendation is to manually create the TTL index. If I were to implement this as a constructor parameter in code we would be creating / updating the index on every execution. I'm not sure this is a good idea.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would simply set the TTL to 0, and append this "clockDrift" parameter to the expiredAt (expiredAt = now + lockTTL + clockDrift). It would both solve the purge issue by Mongo and the race condition by 2 clients.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've moved clock discrepancy handling to a constructor option called drift instead of relying on a TTL index delay.

$expiry = $this->createDateTime(microtime(true) + $this->initialTtl);

$filter = array(
$this->options['resource_field'] => (string) $key,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is there a restriction on key length or key format?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, 1024 bytes including structure overhead (e.g. int / string) I've added this to symfony-docs under the Reliability section and to the phpdoc

$update = array(
'$set' => array(
$this->options['resource_field'] => (string) $key,
$this->options['token_field'] => $this->getToken($key),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the method $this->getToken($key) is already called at line 108

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fixed


$key->reduceLifetime($this->initialTtl);
try {
$this->getCollection()->updateOne($filter, $update, $options);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it atomic? on every version of mongodb? What's about réplication propagation?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MongoDB is atomic on a singular statement / document only. Hence the use of a update with filter (1 statement). MongoDB ^4.0 introduces multi statement transactions however this implementation does not rely on that. It should be atomic on all versions. Replication propagation can be controlled with a statement level options called a write concern. I'm adding support for that now. By default, mongo will read and write only to the PRIMARY node in a replica set (this is equivalent of reading and writing only to the master of a replicated RDBMS with replication slaves).

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With regards to writeConcern, readConcern and readPreference I've opted to leave this to the collection fallback placing the onus on the DBA to setup the collection with alternate settings if desired. The default when left unspecified is to read / write ONLY to the PRIMARY of a replica set. This is safe for the use of locks (it's like only reading / writing to a master in an RDBMS) however an alternate strategy could be left to the DBA depending on the number of nodes they have.

I have added some documentation regarding this decision to symfony-docs

* db.lock.update(
* {
* _id: "test",
* expires_at: {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

whats i the purpose of this doc block (which is not up to date with the current implementation BTW)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was more to help with seeing how the code translated to a MongoDB statement when I was supporting multiple drivers. I have since stripped the multiple driver support (Fabian said don't support PHP ^5 / symfony ^3 so I dropped it). I've now removed these phpdocs

$this->options['resource_field'] => (string) $key,
$this->options['token_field'] => $this->getToken($key),
$this->options['expiry_field'] => array(
'$gte' => $this->createDateTime(),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd rather define a now variable at the beggining of the method, then use this variable as a reference for every computes

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Makes sense, I've fixed this

$key->reduceLifetime($ttl);
try {
$this->getCollection()->updateOne($filter, $update, $options);
} catch (\MongoDB\Driver\Exception\BulkWriteException $e) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same here

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

improved exception handling as mentioned above

try {
$result = $this->getCollection()->deleteOne($filter);
} catch (\MongoDB\Driver\Exception\BulkWriteException $e) {
throw new LockConflictedException('Failed to delete lock', 0, $e);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

deletion does not throw ConflictException

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed

* garbage-collection. Alternatively it's possible to automatically expire
* the locks in the database as described below:
*
* A TTL collections can be used on MongoDB 2.2+ to cleanup expired locks
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The current implementation does not purge expired locks. I'd rather use the verb MUST BE used,

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

changed

@kralos
Copy link
Author

kralos commented May 31, 2018

@jderusse should be good for a second look. Thanks for you feedback so far, really helpful.

* must not exceed 1024 bytes including structural overhead.
* @see https://docs.mongodb.com/manual/reference/limits/#Index-Key-Limit
*
* @param float $initialTtl The expiration delay of locks in seconds
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why $initialTtl is a constructor argument, whereas every other options are passed into $options?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll move it to $options.

I've been thinking it would be simpler to remove the drift option in favour of asking the developer set a slightly higher lockTTL. This would both simplify the implementation and probably make more sense to the developer rather than trying to explain why drift is a separate option. I would have to document the danger of clock drift against this Store. What are your thoughts?

*
* A TTL index MUST BE used on MongoDB 2.2+ to automatically clean up expired locks.
*
* db.lock.ensureIndex(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've added a method to createTTLIndex(). There's no need in MongoDB to create the collection, they're created on the fly by the database.

*
* @see http://docs.mongodb.org/manual/tutorial/expire-data/
*/
public function createTTLIndex(): bool
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return false means a failure or "nothing updated"?
If it's a failure, I suggest to throw an exception instead.
What's about documenting the return?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My bad, I actually dug into this further and my return type bool was incorrect. I was reading the legacy driver documentation. The createIndex call actually does throw an exception if it fails. I have quickly tested this to verify and updated the phpdoc accordingly.

https://docs.mongodb.com/php-library/v1.3/reference/method/MongoDBCollection-createIndex/


$this->options = array_merge(array(
'collection' => 'lock',
'initialTtl' => 300.0,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I really wonder if options for lock should be merge with options for database connection?

See PdoTrait from cache components.

What is the opinion of other contributors?

Copy link
Author

@kralos kralos Jun 6, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I originally had initialTtl as a separate constructor parameter, I brought it into $options when you suggested drift should be.

I personally think it belongs outside of $options.

See also: https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Cache/Adapter/PdoAdapter.php

private $collection;

/**
* @param \MongoDB\Client $mongo
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a way to pass a dns for lazy connection? See PdoTrait from cache components?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A MongoDB\Client instance is already lazy. I can turn off my database and still:

$client = new \MongoDB\Client();
$store = new \Symfony\Component\Lock\Store\MongoDbStore($client, [
    'database' => 'test',
]);

only if I add something like:

$store->createTTLIndex();

does it throw an exception.

@nicolas-grekas
Copy link
Member

@kralos would you mind squashing all commits in one please?

@kralos kralos closed this Jun 19, 2018
@kralos kralos deleted the 27345-lock-mongodb branch June 19, 2018 22:34
@nicolas-grekas
Copy link
Member

Why did you close?

@nicolas-grekas
Copy link
Member

Oh, continued in #27648 thanks :)

@kralos
Copy link
Author

kralos commented Jun 25, 2018

Sorry, I broke this Pull request when squashing... has to open a new one.

@nicolas-grekas nicolas-grekas modified the milestones: next, 4.2 Nov 1, 2018
fabpot added a commit that referenced this pull request Mar 29, 2019
This PR was merged into the 4.3-dev branch.

Discussion
----------

[Lock] Added MongoDBStore

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes (requires `ext-mongodb` and `mongodb/mongodb` to test)
| Fixed tickets | #27345
| License       | MIT
| Doc PR        | symfony/symfony-docs#9807

**Testing caveat**
In order to test this, the test environment needs `ext-mongodb` and `mongodb/mongodb`.

I have both written the test and tested `Symfony\Component\Lock\Store\MongoDbStore` and it does pass in an environment with `ext-mongodb` and `mongodb/mongodb`.

**Description**
We should support Semaphore Locks with a MongoDB back end to allow those that already use MongoDB as a distributed storage engine.

Symfony already partially supports MongoDB for session storage: `Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler`

**Example**

```php
$client = new MongoDb\Client();

$store = new Symfony\Component\Lock\Store\MongoDbStore(
    $client
    array(
        'database' => 'my-app',
    )
);
$lockFactory = new Symfony\Component\Lock\Factory($store);
$lock = $lockFactory->createLock('my-resource');
```

This is a squashed pull request of #27346

Commits
-------

9c04639 #27345 Added Lock/Store/MongoDbStore
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants