Thanks to visit codestin.com
Credit goes to github.com

Skip to content

[Lock] Add MysqlStore that use GET_LOCK #25578

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 10 commits into from
Closed

Conversation

GromNaN
Copy link
Member

@GromNaN GromNaN commented Dec 22, 2017

Q A
Branch? master
Bug fix? no
New feature? yes
BC breaks? no
Deprecations? no
Tests pass? yes
Fixed tickets #25400
License MIT
Doc PR WIP
  • Key is hashed with sha256 to ensure it stays between 1 and 64 characters.

MySQL 5.7.5 and later enforces a maximum length on lock names of 64 characters. Previously, no limit was enforced.

  • Create a new PDO connection for each lock, to avoid multiple locks on the same name in the same session. Using a shared PDO connection lead to edge cases when using multiple lock simultaneously.

It is even possible for a given session to acquire multiple locks for the same name. Other sessions cannot acquire a lock with that name until the acquiring session releases all its locks for the name.

@nicolas-grekas
Copy link
Member

thanks for this PR
fabbot has some valid points,
and travis hangs on the Lock test suite for now :)

use Symfony\Component\Lock\Key;
use Symfony\Component\Lock\StoreInterface;

class MysqlStore implements StoreInterface
Copy link
Contributor

Choose a reason for hiding this comment

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

should be final

Copy link
Member Author

Choose a reason for hiding this comment

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

Why not, but why ?

Copy link
Member

Choose a reason for hiding this comment

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

We keep having this conversation again and again :)
We don't do final by default in Symfony, it would reduce the value of the code base with no real benefit for maintenance.

Copy link
Contributor

Choose a reason for hiding this comment

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

On the contrary, it increases the value of the code base, because the developer can clearly see which are extension points and which are not. It will also force the developer to use composition and service decorators, because they can't get around with ugly hacks such as changing the service class.

It will actually make maintenance a lot easier because it's no longer an extension point 🤔

We don't do final by default in Symfony

Maybe we should ;)

Copy link
Contributor

Choose a reason for hiding this comment

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

We don't do final by default in Symfony

Wait, but it is not by default, it's by @iltar's request. :) I don't see valid use case to inherit MysqlStore as well. What's the point to support it? 🤔

@GromNaN GromNaN force-pushed the get_lock branch 2 times, most recently from f934b95 to d0ea57b Compare December 23, 2017 14:20
* Key is hashed with sha256 to ensure it stays between 1 and 64 characters
* Create a new PDO connection for each lock, to avoid multiple locks on the same name in the same session
* Makes wait timeout configurable
@GromNaN
Copy link
Member Author

GromNaN commented Dec 23, 2017

It is still work in progress.

  • Integrate with framework bundle configuration
  • Load MySQL service in travis
  • Update docs

@GromNaN
Copy link
Member Author

GromNaN commented Dec 27, 2017

In the FrameworkBundle configuration, stores are configured with a single string "dsn". The connection is initialized from this string. But we need more than a dsn to initialize a connection to Mysql (see Doctrine\DBAL\Connection, \PDO of \MySQLi contructors).

  1. What about allowing a service name ?
  2. What about creating a new dsn syntax for db ?
framework:
    lock:
        enabled: true
        resources:
            default:
                - flock
                - semaphore
                - memcached://user:pass@localhost?weight=33
                - redis://example.com:1234

                # New for MysqlStore
                - "@=service(doctrine.dbal.default_connection)"
                - mysql:host=localhost;port=13306;dbname=testdb;user=bruce;password=mypass

                # Upcoming, the PDO Postgres driver already accept this syntax:
                - pgsql:host=localhost;port=5432;dbname=testdb;user=bruce;password=mypass

/cc @jderusse

@jderusse
Copy link
Member

Issue with storing key in database is: the lock has a dependency to the connection:

  • what if connection is down (think long process in console)
  • how to not auto release the lock and keep it between 2 http calls?

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.

IMO we can use dsn without extra service configuration. All required information should be available in dsn.

BTW I would love to use such syntax mysql://root:password@localhost/my_database

}

/**
* {@inheritdoc}
Copy link
Member

Choose a reason for hiding this comment

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

Do we still keep inheritDoc comments?

Copy link
Member

Choose a reason for hiding this comment

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

Yes

// no timeout for impatient
$timeout = $blocking ? $this->waitTimeout : 0;

$connection = new \PDO($this->dsn, $this->username, $this->password, $this->options);
Copy link
Member

Choose a reason for hiding this comment

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

Here you open a new connection for each lock (is it necessary?). It could trigger a max connections limit. Moreover you don't close theme.

Copy link
Member Author

@GromNaN GromNaN Dec 27, 2017

Choose a reason for hiding this comment

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

The connection is closed automatically when the PDO instance is destructed. That's done by removing the PDOStatement from the key state.

I chose to open a new connection for each lock to be sure that we don't get the same lock multiple times on the same connection.

In MySQL 5.7.5, GET_LOCK() was reimplemented using the metadata locking (MDL) subsystem and its capabilities were extended. Multiple simultaneous locks can be acquired and GET_LOCK() does not release any existing locks. It is even possible for a given session to acquire multiple locks for the same name. Other sessions cannot acquire a lock with that name until the acquiring session releases all its locks for the name.

But if we allow the injection of a connection, we need to keep this state in-memory in the MysqlStore instance.

Copy link
Member

Choose a reason for hiding this comment

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

Indeed by sharing PDO you have to to avoid concurrency inside the same process. This side effect should be warned in the documentation IMO

Copy link
Member Author

Choose a reason for hiding this comment

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

I've found this workaround to avoid multiple connections:

SELECT IF(IS_USED_LOCK(:key) IS NULL, GET_LOCK(:key, :timeout), 0)

Commit is coming.

Copy link
Member

Choose a reason for hiding this comment

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

be sure that we don't get the same lock multiple times on the same connection.

Is this really what we want? Looks opinionated to me. flock() doesn't blocks when acquiring twice a lock on a file, I would expect the same here, so that I have more choices in the end (my responsibility to create several connections if I need intra process locking)

Copy link
Member

Choose a reason for hiding this comment

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

@nicolas-grekas there is a test for that (https://github.com/symfony/symfony/blob/master/src/Symfony/Component/Lock/Tests/Store/AbstractStoreTest.php#L80). Tools like amphp (ReactPHP) needs locks inside the same process.

Copy link
Member

Choose a reason for hiding this comment

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

But same key (remember, a key contains the resource locked + the lock state) can be reused and locked several times inside the same process.

Copy link
Member

Choose a reason for hiding this comment

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

Ok indeed

$this->username = $options['db_username'] ?? '';
$this->password = $options['db_password'] ?? '';
$this->options = $options['db_connection_options'] ?? array();
$this->waitTimeout = $options['wait_timeout'] ?? -1;
Copy link
Member

Choose a reason for hiding this comment

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

In others stores blocking mode is waiting forever. Such option is not needed IMO

Copy link
Member Author

Choose a reason for hiding this comment

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

The "wait timeout" feature was not available in flock and semaphore, isn't it the reason why it was not implemented? It is a useful alternative to RetryTillSaveStore when the lock needs to be acquired in a limited time with a clean error handling.

Copy link
Member

Choose a reason for hiding this comment

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

It wasn't implemeted in the store because I didn't see end to end use case, and such feature can be handled by a decorator and bring the feature to every store.

@nicolas-grekas
Copy link
Member

My 2cts: it's fine if drivers have their specificities, and creating one connection per lock is too opinionated and reduces a lot the value of the implementation.

@GromNaN
Copy link
Member Author

GromNaN commented Dec 28, 2017

PR updated trying to get the best from all the great reviews.

Here is the implemented behavior (updated 2018-01-19) :

  • non-blocking mode
    • IF already locked by other THEN conflict
    • IF already locked by same connection THEN conflict
    • IF not locked THEN lock with GET_LOCK
  • blocking mode
    • IF already locked by other THEN wait with GET_LOCK
    • IF already locked by same connection THEN conflict
    • IF not locked THEN lock with GET_LOCK

what if connection is down (think long process in console)

Releasing the lock when the connection is lost is a feature of this specific driver. Especially useful when the task that needs this lock use the database.

how to not auto release the lock and keep it between 2 http calls?

It seems impossible. I don't have this use case.

creating one connection per lock is too opinionated and reduces a lot the value of the implementation.

Modified to inject the connection in the constructor: an instance of PDO or Doctrine\DBAL\Connection.

@GromNaN GromNaN force-pushed the get_lock branch 2 times, most recently from 3816d61 to 02558a5 Compare December 28, 2017 16:23
@jderusse
Copy link
Member

jderusse commented Dec 28, 2017

The fact that the lock is auto released when the connection to the DB is down is not a feature. Some workflow don't need connection to the DB at 100% (using external web services, sending emails, the application may be able to renew/reconnect the connection to finish the job).

Moreover the user should be able to know that it didn't have the lock anymore (which is not the case with the current implement) and should be addressed in that PR IMHO.
What's about using the putOff method to refresh the connection to MySQL (with a ping for instance) and better increase the connection TTL?

@GromNaN
Copy link
Member Author

GromNaN commented Dec 29, 2017

Good idea @jderusse, I've modified the putOffExpiration method to extend the wait_timeout to the given TTL.

@willemstuursma
Copy link

Note that you cannot nest locks in MySQL in MySQL < 5.7.5. This limitation is pretty important and should be documented.

Before 5.7.5, only a single simultaneous lock can be acquired and GET_LOCK() releases any existing lock.

public function putOffExpiration(Key $key, $ttl)
{
// the GET_LOCK locks forever, until the session terminates.
$stmt = $this->connection->exec('SET SESSION wait_timeout=GREATEST(@@wait_timeout, :ttl)');
Copy link
Contributor

Choose a reason for hiding this comment

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

hmm if someone uses the passed $connection elsewhere (maybe in a long running cli process) then this could lead to surprises as it can be closed earlier than expected?

Copy link
Member Author

Choose a reason for hiding this comment

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

This SQL statement can only increase the wait_timeout (takes the max between the current value and the minimum expected value).

If the connection is closed intentionally, then the lock is automatically released. It is a limitation/feature of this locking mecanism.

$success = $stmt->fetchColumn();

if ($blocking && '-1' === $success) {
throw new LockAcquiringException('Lock already acquired with the same MySQL connection.');
Copy link
Member

Choose a reason for hiding this comment

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

Throwing that exception in blocking mode is weird IMO, the purpose of the the blocking is to wait until the lock can be acquired.

Use case: With ReactPHP and a Session Storage that used this MySQL store and 2 concurrent HTTP request (think ajax). The second HTTP request shouldn't fail just because it performs a "session_start" which trigger the lock

Copy link
Member Author

Choose a reason for hiding this comment

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

By default, the wait timeout is infinite (-1). This exception will be thrown in a specific scenario that is not

I've updated to use this "wait timeout" feature in non-blocking mode and throw the same LockConflictedException if the lock is token by an other or the by the same connection.

}

// store the release statement in the state
$releaseStmt = $this->connection->prepare('DO RELEASE_LOCK(:key)');
Copy link
Member

Choose a reason for hiding this comment

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

now connection is injected in this class, do we still need to store the statement in the key?

Copy link
Member Author

Choose a reason for hiding this comment

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

No, I've updated the code to prepare the statement in the delete method.

@@ -50,6 +50,7 @@ public function testBlockingLocks()
// Wait the start of the child
pcntl_sigwaitinfo(array(SIGHUP), $info);

$store = $this->getStore();
Copy link
Member

Choose a reason for hiding this comment

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

good catch

Copy link
Member Author

@GromNaN GromNaN left a comment

Choose a reason for hiding this comment

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

I've updated the class to use the wait timeout in non-blocking mode. It allows the method $lock->acquire($blocking=false) to wait for a limited time. By default, this time is 0.

public function putOffExpiration(Key $key, $ttl)
{
// the GET_LOCK locks forever, until the session terminates.
$stmt = $this->connection->exec('SET SESSION wait_timeout=GREATEST(@@wait_timeout, :ttl)');
Copy link
Member Author

Choose a reason for hiding this comment

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

This SQL statement can only increase the wait_timeout (takes the max between the current value and the minimum expected value).

If the connection is closed intentionally, then the lock is automatically released. It is a limitation/feature of this locking mecanism.

$success = $stmt->fetchColumn();

if ($blocking && '-1' === $success) {
throw new LockAcquiringException('Lock already acquired with the same MySQL connection.');
Copy link
Member Author

Choose a reason for hiding this comment

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

By default, the wait timeout is infinite (-1). This exception will be thrown in a specific scenario that is not

I've updated to use this "wait timeout" feature in non-blocking mode and throw the same LockConflictedException if the lock is token by an other or the by the same connection.

}

// store the release statement in the state
$releaseStmt = $this->connection->prepare('DO RELEASE_LOCK(:key)');
Copy link
Member Author

Choose a reason for hiding this comment

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

No, I've updated the code to prepare the statement in the delete method.

@GromNaN
Copy link
Member Author

GromNaN commented Jan 19, 2018

@willemstuursma the following query ensure a consistent behavior with older mysql versions as it prevent nested lock. It returns -1 if the lock has already been acquired by the same connection : SELECT IF(IS_USED_LOCK(:key) = CONNECTION_ID(), -1, GET_LOCK(:key, :timeout))

Edit: Tested on MySQL 5.6, it doesn't support acquiring multiple locks on the same session, even with different keys. This needs to be documented or detected cleanly.


$storedKey = $key->getState(__CLASS__);

$stmt = $this->connection->prepare('SELECT IF(IS_USED_LOCK(:key) = CONNECTION_ID(), 1, 0)');
Copy link
Member Author

@GromNaN GromNaN Jan 19, 2018

Choose a reason for hiding this comment

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

@jderusse This query insure that: 1. the connection is still alive & 2. the lock is still assigned to the current connection.

@willemstuursma
Copy link

@GromNaN

Tested on MySQL 5.6, it doesn't support acquiring multiple locks on the same session, even with different keys. This needs to be documented or detected cleanly.

It's very nasty, there should be an exception if it tries to get a lock if the session already has one, else the other lock will (silently) be released.


/**
* @param \PDO|Connection $connection
* @param int $waitTimeout Time in seconds to wait for a lock to be released, for non-blocking lock.
Copy link
Member

Choose a reason for hiding this comment

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

To be acquired, and not to be released right?

Copy link
Contributor

Choose a reason for hiding this comment

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

It will be released after the timeout so that sounds right to me. You could also say "The time in seconds the lock is acquired for".

Copy link
Member

Choose a reason for hiding this comment

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

Regarding the documentaiton the parameter timeout, define the maximum amount of time allowed to acquire the lock. Once you get it, you can keep it as long as you want.

T0: ask for acquiring a new lock (wait for other process to release the lock for instance)
T1: lock is acquired
T2: lock is released

Here we are talking about the time between T1 and T0.

$stmt->execute();

// 1: Lock successful
// 0: Already locked by another session
Copy link
Member

Choose a reason for hiding this comment

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

Null, process killed by admin, or machine out of memory

@nicolas-grekas nicolas-grekas modified the milestones: 4.1, next Apr 20, 2018
@jderusse
Copy link
Member

There is a big issue with the current implementation of the store. The entiere mechanism rely on the connection between the application and the database. If for some reason the connection is closed (or the database restart, or whatever), the "lock" is released for the database, but the application is not aware of that and continue to work.

I'd rather use the same pattern as #27346 by creating a dedicated table, and use atomic queries like

INSERT INTO lock_table (field, token, expired_at)
  VALUES (:key, :token, :date)
ON DUPLICATE KEY UPDATE
  expired_at = IF(token = VALUES(token), VALUES(expired_at), expired_at);
// then check it
SELECT COUNT(*) FROM lock_table WHERE field=:key AND token := token
COMMIT;

This pattern is IMO safer, and could be used by most of database and version
A garbage collection to purge old locks could be run randomly

@GromNaN
Copy link
Member Author

GromNaN commented Jun 5, 2018

I don't have time to rework this PR. I close it to let anyone interested works on this feature.

@GromNaN GromNaN closed this Jun 5, 2018
fabpot added a commit that referenced this pull request Sep 4, 2018
This PR was merged into the 4.2-dev branch.

Discussion
----------

[LOCK] Add a PdoStore

| Q             | A
| ------------- | ---
| Branch?       | master
| Bug fix?      | no
| New feature?  | yes
| BC breaks?    | no
| Deprecations? | no
| Tests pass?   | yes
| Fixed tickets | #25400
| License       | MIT
| Doc PR        | symfony/symfony-docs#9875

This is an alternative to #25578

Commits
-------

46fe1b0 Add a PdoStore in lock
@nicolas-grekas nicolas-grekas modified the milestones: next, 4.2 Nov 1, 2018
@rtek rtek mentioned this pull request Apr 9, 2022
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.

10 participants