laravel / pennant

A simple, lightweight library for managing feature flags.
https://laravel.com/docs/pennant
MIT License
479 stars 49 forks source link

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

Closed timacdonald closed 5 months ago

timacdonald commented 5 months ago

fixes #101

Problem

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

https://github.com/laravel/pennant/blob/83178d76f41d45276da9ce37cf7d76c9f5b28945/database/migrations/2022_11_01_000001_create_features_table.php#L23

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.

StSarc commented 5 months ago

@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: https://github.com/laravel/pennant/issues/100

timacdonald commented 5 months ago

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

timacdonald commented 5 months ago

https://github.com/laravel/pennant/pull/105