Database cache driver does not work correctly with parallel tests
Laravel Version
11.x
PHP Version
any
Database Driver & Version
irrelevant
Description
This is similar to #53692 but a separate issue.
It seems creating a DB connection too early (in a service provider, in a specific way) and using it later causes some weird "desynchronization" when using parallel tests.
I haven't had the time to do a deep dive yet, but the issue is likely related to some reconnecting logic similar to the linked issue.
The reproduction steps may seem very specific, i.e. why am I instantiating a cache store in a service provider. In practice this happens if you just use RateLimiter::for('foo', static fn () => null), but that under the hood creates the cache store which seems to be the underlying cause. So the reproduction steps are as low level as I managed to trace this issue.
In a real app, e.g. one based on Fortify which rate limits logins, any call to cache() while using the database cache driver, will cause weird behavior. There is a test in Jetstream that directly reproduces this (it makes a request to the login route) but the side effects don't have a perceptible effect. If there were some more DB-related assertions after the request, especially comparing DB state before the request and after, you'd see it.
Steps To Reproduce
laravel new, no starter kit, select Pest for instance- Make the following changes in
phpunit.xml:<env name="CACHE_STORE" value="database"/> <env name="DB_CONNECTION" value="sqlite"/> <env name="DB_DATABASE" value="database/tests.sqlite"/> touch database/tests.sqlite- Force cache store creation in a service provider:
// AppServiceProvider::boot() cache()->getStore(); - Add a test:
test('foobar', function () { expect(DB::select('select * from users'))->toHaveCount(0); User::create(['name' => 'John Doe', 'email' => '[email protected]', 'password' => 'foobar']); expect(DB::select('select * from users'))->toHaveCount(1); cache()->put('foo', 'bar'); expect(cache('foo'))->toBe('bar'); // users table is empty! expect(DB::select('select * from users'))->toHaveCount(1); }); artisan test -pshould fail on the last assertion- If you remove the service provider call, the test passes
Thank you for reporting this issue!
As Laravel is an open source project, we rely on the community to help us diagnose and fix issues as it is not possible to research and fix every issue reported to us via GitHub.
If possible, please make a pull request fixing the issue you have described, along with corresponding tests. All pull requests are promptly reviewed by the Laravel team.
Thank you!
Confirm that this issue is the same as #53692, but the current behavior seems related to the fix in #53693.
The current behavior exhibits two patterns:
RateLimiterandCacheretainStoreinstances that reference connections afterDB::purge().- Previous connections, when reconnecting, modify the PDO instance of the current connection.
Here is some code that demonstrates this behavior:
test('foobar', function () {
\Illuminate\Support\Facades\DB::listen(static function ($query) {
$с = $query->connection;
echo sprintf('Database: %s. Connection: #%d, Pdo: #%d', $с->getDatabaseName(), spl_object_id($с), spl_object_id($с->getPdo())) . PHP_EOL;
echo sprintf('Query: %s', $query->toRawSql()) . PHP_EOL . PHP_EOL;
});
expect(DB::select('select * from users'))->toHaveCount(0);
User::create(['name' => 'John Doe', 'email' => '[email protected]', 'password' => 'foobar']);
expect(DB::select('select * from users'))->toHaveCount(1);
cache()->put('foo', 'bar');
expect(cache('foo'))->toBe('bar');
// Users table is empty!
expect(DB::select('select * from users'))->toHaveCount(1);
});
The output demonstrates the following behavior:
Database: database/app.sqlite_test_1. Connection: #1410, Pdo: #1417
Query: select * from users
Database: database/app.sqlite_test_1. Connection: #1410, Pdo: #1417
Query: insert into "users" ("name", "email", "password", "updated_at", "created_at") values (....)
Database: database/app.sqlite_test_1. Connection: #1410, Pdo: #1417
Query: select * from users
[LOOK AT ME 1] Database: database/app.sqlite. Connection: #943, Pdo: #2573
Query: insert into "cache" ("expiration", "key", "value") values (2055371018, 'foo', 's:3:"bar";') on conflict ("key") ...
[LOOK AT ME 2] Database: database/app.sqlite. Connection: #943, Pdo: #2573
Query: select * from "cache" where "key" in ('foo')
[LOOK AT ME 3] Database: database/app.sqlite_test_1. Connection: #1410, Pdo: #2573
Query: select * from users
The PDO object ID changes from #1417 to #2573, indicating that the previous connection's PDO instance replaces the current one.
Adding Cache::purge() to switchToDatabase() doesn't fully resolve this, as RateLimiter receives its cache Repository through constructor injection (which holds a reference to DatabaseStore).
Solution for Parallel Testing
Ideally, we should have the correct database name (with the TEST_TOKEN suffix) before the application boots. This approach allows us to avoid logic such as DB::purge, which are invoked after the application has already booted. By setting the database name at the configuration stage, we can ensure that the application uses the correct database connection from the start, simplifying the process and avoiding potential issues with connection state.
@nunomaduro pinging you as the author of the parallel tests integration in Laravel. WDYT ?
Yeah, when testing this I've observed specifically that the cache call changes the PDO object. Though note the linked fix does address a sort of "worse" bug, one that also pops up in some parallel testing setups, where you can get a completely invalid Connection instance rather than just its underlying PDO swapped.
My thinking is this is something to be addressed in the parallel testing logic rather than cache (as that's not the root of the problem, just one case where it happens — it'd be helpful to have a reproduction unrelated to cache) or the DB manager.
That said, I think this still needs a bit more context about what exactly happens as a result of those PDO objects getting swapped.
One solution to this problem is to use the memory for storing data instead of managing with a file based database. In your phpunit.xml, just change this line to :memory instead of an sqlite file to store data:
<env name="DB_DATABASE" value=":memory:"/>
Now when you run the parallel tests, it will pass every single time with the same PDO instance. Here's a Screenshot of my experiment:
Not looking for workarounds. The bug is reported as existing with database files.