Skip to content
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

[1.x] Improve unique constraint handling for high traffic applications #104

Merged
merged 6 commits into from
May 21, 2024

Conversation

timacdonald
Copy link
Member

@timacdonald timacdonald commented May 21, 2024

fixes #101

Problem

Laravel Pennant implements a unique constraint that can cause race conditions:

The race condition can be illustrated with the following application setup:

echo "Create project:"
composer create-project laravel/laravel pennant-race-condition
cd pennant-race-condition
composer require laravel/pennant
php artisan vendor:publish --tag=pennant-migrations
sed -i "s/DB_CONNECTION=sqlite/DB_CONNECTION=mysql/" .env
sed -i "s/# DB_DATABASE=laravel/DB_DATABASE=pennant_race_condition/" .env
php artisan migrate --force

echo "Create command to invoke:"
echo "<?php

use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Laravel\Pennant\Feature;

Artisan::command('dev', function () {
    \$name = 'foo-'.time();

    Feature::define(\$name, fn () => 'okay');

    echo Feature::value('foo-'.time()).PHP_EOL;
});" > routes/console.php

echo "Create script that will invoke the command simulating high traffic:"
echo "php artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nphp artisan dev &\nwait" > script.sh

To simulate the race condition, you may invoke the script:

bash script.sh

Solution

We now retry when hitting a UniqueConstraintViolationException. For a single feature , we only retry once, i.e., two total attempts. For several individual features, via getAll, we retry twice, i.e., 3 total attempts.

This is similar to how the framework handles these internally with createOrFirst.

Why not a cache lock

For indivdual features, we could use a cache lock.

When retrieving several features at a time, like you might do with Feature::loadMissing([/* ... */]); to avoid n+1 queries, a cache lock becomes complicated.

We would need an indivudal cache lock for each feature you are loading. We would need to aquire the cache lock before retrieving, which means every single feature retrievial now requires a cache lock, even if it will never result in a race condition.

If you are using the database driver, now you have increased the queries required to retrieve a feature.

I feel that simply retrying for those rare cases where a race condition will occur is the best way forward. It means 99% of retrievals happen with not speed impact and there is a small cost when a race condition happens to occur.

@timacdonald timacdonald marked this pull request as draft May 21, 2024 00:36
Comment on lines -279 to -296
$exists = $this->newQuery()
->where('name', $feature)
->where('scope', $serialized = Feature::serializeScope($scope))
->exists();

if (! $exists) {
return false;
}

$this->newQuery()
return (bool) $this->newQuery()
->where('name', $feature)
->where('scope', $serialized)
->where('scope', Feature::serializeScope($scope))
->update([
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
static::UPDATED_AT => Carbon::now(),
]);

return true;
Copy link
Member Author

@timacdonald timacdonald May 21, 2024

Choose a reason for hiding this comment

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

This was needlessly doing two queries: 1 to check the existence and another to make the update.

Updated to only do a single update statement to better support high traffic applications and reduce queries.

Comment on lines -248 to +287
if (! $this->update($feature, $scope, $value)) {
$this->insert($feature, $scope, $value);
}
$this->newQuery()->upsert([
'name' => $feature,
'scope' => Feature::serializeScope($scope),
'value' => json_encode($value, flags: JSON_THROW_ON_ERROR),
static::CREATED_AT => $now = Carbon::now(),
static::UPDATED_AT => $now,
], uniqueBy: ['name', 'scope'], update: ['value', 'updated_at']);
Copy link
Member Author

@timacdonald timacdonald May 21, 2024

Choose a reason for hiding this comment

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

When telling Pennant to set a feature, we now utilise upsert instead of attempting to update and then insert if there is nothing to update. This has a positive impact on the number of database queries the library runs.

@timacdonald timacdonald marked this pull request as ready for review May 21, 2024 02:14
@taylorotwell taylorotwell merged commit 2226b13 into laravel:1.x May 21, 2024
6 checks passed
@timacdonald timacdonald deleted the traffic branch May 21, 2024 23:59
@StSarc
Copy link

StSarc commented May 28, 2024

@timacdonald This might be the wrong place to ask, but since it's related, asking here (please let me know if you prefer me creating a issue instead, I'll do that right away)

Can we have insertAll function similar to the insert function present in the databaseDriver already instead of having the query explicit in the getAll function for inserting all?

If it makes sense, I can raise a PR for the same.

This will primarily help with overriding functions in DatabaseDriver. Currently, I just need to override the "writes" to the DB. But, since we have a write inside the getAll function, I have to override the getAll function too unnecessarily.
Related issue by someone else: #100

@timacdonald
Copy link
Member Author

I don't see anything wrong with an insertMany extracted function 👍

@timacdonald
Copy link
Member Author

#105

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Database driver could support high frequency requests
3 participants