-
Notifications
You must be signed in to change notification settings - Fork 11.7k
[5.8] Allow locks to be secure against out of order releases #26645
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
|
This should target the master (5.8) branch as there are breaking changes (adding a method to a contract etc.). |
|
Is there any reason this isn't the new default logic? It looks like it's opt-in by calling safe(). |
|
@sisve you're right this is opt-in. This is to keep the API stable, especially true for 5.7 as the docs currently suggest using locks like: if (Cache::lock('foo', 10)->get()) {
// Lock acquired for 10 seconds...
Cache::lock('foo')->release();
}Which means the probability of having production apps out there doing it that way is very high. Making it the default behaviour will make the example above break as the To use the scoped logs you have to retain the lock instance. So the example above translated would be: $lock = Cache::lock('foo', 10)->safe();
if ($lock->get()) {
// Lock acquired for 10 seconds...
$lock->release();
}What you could also do without keeping the instance: if (Cache::lock('foo', 10)->scoped($someUniqueProcessIdentifier)->get()) {
// Lock acquired for 10 seconds...
Cache::lock('foo')->scoped($someUniqueProcessIdentifier)->release();
}This approach could become the default in 5.8 in disregard of the drawback that it makes the API a little less pleasant. IMO it is a good trade off to make as it will make the locking way more robust. As I wrote above I will follow up with a second PR for 5.8 after this one was accepted and everyone is on the same page. One idea for 5.8 would be to return a if ($lock = Cache::lock('foo', 10)->get()) {
// Lock acquired for 10 seconds...
$lock->release();
}The only thing I am currently not super happy with is the wording |
|
Retaining a lock instance may not always be possible though? For example, you could acquire a lock during a web request, fire a queued job, when that queued job is finished you would release the lock from the job. |
src/Illuminate/Cache/RedisLock.php
Outdated
| { | ||
| $this->redis->del($this->name); | ||
| if ($this->canRelease()) { | ||
| $this->redis->del($this->name); |
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.
There is a race condition here. It's possible for canRelease to return true, the lock to then be released because it was expired, and another client to acquire the lock before del is called. If that happens you will delete the other clients lock instead of your own.
With Redis you can use a Lua script to perform an atomic operation. Since scripts are blocking no other client can acquire the lock between the check and the delete.
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
endUnfortunately I don't think there is a good solution for memcached.
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.
Couldn't we use a transaction for that? The Redis docs say:
It can never happen that a request issued by another client is served in the middle of the execution of a Redis transaction.
For Memcached the cas command can be used setting it to null. Though this would eliminate the possibility of storing the lock for later release as @taylorotwell pointed out above, as the cas assumes a state on the client: http://php.net/manual/de/memcached.cas.php
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.
Couldn't we use a transaction for that?
You need to check if the key matches the token before deleting it. I don't think there is an equivalent Redis command that does that. Redis transactions aren't like database transactions, the GET will just be queued until EXEC and you can't read the value of the key in PHP before you call EXEC.
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 GET will just be queued until EXEC
yep, right just tried that
If you needed to use the same lock instance maybe you could pass the lock token around? i.e. // in web request
$lock = Cache::lock('foo', 10)->get();
$job = SomeJob::dispatch($lock->token());
// in worker
$lock = Cache::lock('foo', 10, $this->token)->get();In this example the token parameter would be optional, and if provided would be used instead of the random token. If an existing token is used you would need to check if the lock is still held with that token instead of trying to obtain a new lock (Actually, maybe that makes sense as a new method 😆 ). |
|
What @yuloh said, actually using the |
|
After some research I renamed the |
|
@taylorotwell actually after trying it out I can confirm that you can dispatch a queued job (tested using the |
|
Personally I would target all of this towards 5.8 and try to write it in the best way we can, and accept the current state of 5.7 locks as just having a known limitation. Laravel 5.8 is due in February so we aren't far off. |
|
@taylorotwell totally fine with me. I will change the PR to default to owned locks without the need of explicitly stating it and target the 5.8 branch. |
Before this change out of order releases of the same cache lock could lead to situation where client A acquired the lock, took longer than the timeout, which made client B successfully acquire the lock. If A now finishes while B is still holding the lock A will release the lock that does not belong to it. This fix introduces a unique value that is written as the cache value to stop A from deleting B's lock.
|
@janpantel I don't totally see a way around that if you want locks to work this way? You would at least need to know the scope. Have you researched how |
|
It looks like |
|
@taylorotwell you just wrote that when I was about to answer ;-) . Yes exactly, they also use a LUA script for Redis. So given my real world test with my 5.7 code it was possible to hand the lock into a job which gets serialised into a Redis queue. The release was successful when done from the Job instance. My biggest struggle currently is writing an integration test that can ensure that automatically. Is there a way to start a queue listener in the integration tests or maybe to at least work through the queue contents? |
| /** | ||
| * Returns the owner value written into the driver for this lock. | ||
| * | ||
| * @return mixed |
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 probably want this to be string|null?
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.
Actually the nullability of the owner field is just an artifact that was left from the 5.7 implementation. Shouldn't we move to string only for all @return and @var (except for the constructors)?
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.
@GrahamCampbell any opinion on that one?
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, if that is their type.
|
Eh, I don't think testing that specifically is important as simply testing the creation of a lock from an existing scope, etc. |
|
How about |
|
Added the tests and changed the memcached implementation. $original = Cache::lock('foo', 10)->get();
$owner = $original->getOwner();
$second = Cache::lock('foo', 10, $owner);
$second->release();My first reaction was that it might make sense to move the expiry seconds to the Cache::lock('foo')->get(10);I think that would also be cool semantically as it reads like What do you guys think? |
|
Hmm, yeah I don't think being forced to pass the expiry time in order to release the lock makes much sense. |
|
It seems like you could also work around that by adding another method that accepts the owner as its second argument for reconstituting locks... Cache::from('foo', $owner)... etc. |
|
Are there plans to keep working on this? |
|
@taylorotwell yes, just had some downtime due to the holidays. I will add a commit introducing Cache::from() later. Also I'm waiting to @GrahamCampbell to answer :-) |
|
I've replied. Please let me know if you need anything else. |
|
@GrahamCampbell The only thing left is to remove the request changes if you're fine with the current state. @taylorotwell I created the |
|
Can you just comment with the basic usage documentation given the current API so I can look over it and use it as a guide in my own testing? |
|
@taylorotwell for sure. I will also go ahead and update the real docs once we have committed on the API. To obtain a lock and release it it is now necessary to carry the instance around. if ($lock = Cache::lock('foo', 10)->get()) {
// do stuff
$lock->release()
}If you are sure that there will be no race condition or the out of order release does not trouble you you can also use the if (Cache::lock('foo', 10)->get()) {
// do stuff
Cache::lock('foo')->forceRelease()
}If you have to release the lock in another process you can get the owner token from the lock instance and restore it later. if ($lock = Cache::lock('foo', 10)->get()) {
// do stuff
event(new LockReleasingEvent($lock->getOwner()));
}
// later ...
$lock = Cache::restoreLock('foo', $owner);
// do stuff
$lock->releaseA counter example, as in how to not do it would be: if (Cache::lock('foo', 10)->get()) {
// do stuff
Cache::lock('foo')->release()
/*
lock still in place as the constructor generates a random
owner token each time a lock instance is created
*/
} |
|
Nice work. I made the necessary changes to the new DynamoDbLock as well. |
|
@janpantel thanks for your work on this! 👏 |
|
Thanks for making my first major contribution such a pleasant experience! :-) |
Resolves: #26213
Before this change out of order releases of the same cache lock could lead to situation where client A acquired the lock, took longer than the timeout, which made client B successfully acquire the lock. If A now finishes while B is still holding the lock A will release the lock that does not belong to it any more.
This fix introduces a unique value that is written as the cache value to stop A from deleting B's lock.
Example from the original issue:
This change does not break the existing API as it introduces the
scoped($scope)function, plus a sugar functionsafe()that generates a random$scopeusing theuniqid()function.This is most likely something that should be default for 5.8 (I will create a follow up PR after this one is merged) as it makes Cache locking much more robust. For 5.7 the opt-in is important as it could otherwise disrupt already existing locks in production deployments.
Happy to discuss the API of this :-)