-
-
Notifications
You must be signed in to change notification settings - Fork 9.6k
[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
Conversation
GromNaN
commented
Dec 22, 2017
•
edited
Loading
edited
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.
- 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.
thanks for this PR |
use Symfony\Component\Lock\Key; | ||
use Symfony\Component\Lock\StoreInterface; | ||
|
||
class MysqlStore implements StoreInterface |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should be final
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why not, but why ?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ;)
There was a problem hiding this comment.
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? 🤔
f934b95
to
d0ea57b
Compare
* 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
It is still work in progress.
|
87318d0
to
387507c
Compare
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
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 |
Issue with storing key in database is: the lock has a dependency to the connection:
|
There was a problem hiding this 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} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
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. |
PR updated trying to get the best from all the great reviews. Here is the implemented behavior (updated 2018-01-19) :
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.
It seems impossible. I don't have this use case.
Modified to inject the connection in the constructor: an instance of |
3816d61
to
02558a5
Compare
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. |
Good idea @jderusse, I've modified the |
Note that you cannot nest locks in MySQL in MySQL < 5.7.5. This limitation is pretty important and should be documented.
|
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)'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.'); |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)'); |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
good catch
There was a problem hiding this 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)'); |
There was a problem hiding this comment.
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.'); |
There was a problem hiding this comment.
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)'); |
There was a problem hiding this comment.
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.
@willemstuursma 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)'); |
There was a problem hiding this comment.
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.
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. |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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".
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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
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
This pattern is IMO safer, and could be used by most of database and version |
I don't have time to rework this PR. I close it to let anyone interested works on this feature. |
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