Skip to content

Instantly share code, notes, and snippets.

@troatie
Last active September 12, 2023 13:51
Show Gist options
  • Star 45 You must be signed in to star a gist
  • Fork 9 You must be signed in to fork a gist
  • Save troatie/def0fba42fcfb70f873b7f033fbe255f to your computer and use it in GitHub Desktop.
Save troatie/def0fba42fcfb70f873b7f033fbe255f to your computer and use it in GitHub Desktop.
Guard against race conditions in Laravel's firstOrCreate and updateOrCreate
trait CreatesWithLock
{
public static function updateOrCreate(array $attributes, array $values = [])
{
return static::advisoryLock(function () use ($attributes, $values) {
// emulate the code found in Illuminate\Database\Eloquent\Builder
return (new static)->newQuery()->updateOrCreate($attributes, $values);
});
}
public static function firstOrCreate(array $attributes, array $values = [])
{
return static::advisoryLock(function () use ($attributes, $values) {
return (new static)->newQuery()->firstOrCreate($attributes, $values);
});
}
/**
* In my project, this advisoryLock method actually lives as a function on the global namespace (similar to Laravel Helpers).
* In that case the $lockName, and default lock duration are pased in as arguments.
*/
private static function advisoryLock(callable $callback)
{
// Lock name based on Model.
$lockName = substr(static::class . ' *OrCreate lock', -64);
// Lock for at most 10 seconds. This is the MySQL >5.7.5 implementation.
// Older MySQL versions have some weird behavior with GET_LOCK().
// Other databases have a different implementation.
\DB::statement("SELECT GET_LOCK('" . $lockName . "', 10)");
$output = $callback();
\DB::statement("SELECT RELEASE_LOCK('" . $lockName . "')");
return $output;
}
}
@Richtermeister
Copy link

Big fan of this approach - glad to see you mention it over at https://murze.be/breaking-laravels-firstorcreate-using-race-conditions
Just a quick clarification for any passer-bys: The second parameter is not a timeout on the obtained lock, but a timeout on trying to obtain the lock. Once you hold the lock it is valid until you release it or connection closes. -1 is used to wait indefinitely, 0 just executes a check and moves on, much like a Redis lock would.

@williamjulianvicary
Copy link

Great approach to the problem, thank you.

@amenk
Copy link

amenk commented Oct 28, 2018

Is that a solution for laravel/framework#19372 ? Maybe make a pull request? @troatie

@francislavoie
Copy link

@amenk problem is this is MySQL-specific. For Laravel to accept the PR, it would need to be DB agnostic.

@trevorgehman
Copy link

This is great, a big help.

@olivernybroe
Copy link

Nice solution, It however only solves it when on the model itself. In our use case we also call updateOrCreate on relationships.

@dalsn
Copy link

dalsn commented Feb 7, 2023

What kind of problems could arise from using a lock on the DB? Wouldn't this be a problem for high traffic applications?

@JuanS
Copy link

JuanS commented Apr 13, 2023

Nice solution but I'd use a cache lock instead of a db lock https://laravel.com/docs/10.x/cache#atomic-locks

@mpyw
Copy link

mpyw commented Sep 2, 2023

This has been completely resolved since Laravel 10.22.0, Please read it through translation: [Laravel] createOrFirst の登場から激変した firstOrCreate, updateOrCreate に迫る!

If retry processing under unique key constraints is not sufficient: mpyw/laravel-database-advisory-lock: Advisory Locking Features for Postgres/MySQL/MariaDB on Laravel

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment