framework icon indicating copy to clipboard operation
framework copied to clipboard

[11.x] Allows to persist database connection between tests and only reset PDO in `afterClassTearDown()`

Open crynobone opened this issue 1 year ago • 15 comments

Background

PDO connection doesn't automatically get closed when the application is unset but instead requires explicitly set to null to trigger connection close:

CleanShot 2023-12-15 at 15 35 46

https://www.php.net/manual/en/pdo.connections.php

Since PHPUnit is especially a long-running process when the application gets booted and terminated on each test the PDO may persist and eventually can cause max database connections. To solve we have two options:

Option A: We can explicitly disconnect the database between tests

Disadvantages:

  • Caused breaking change to the existing application since it is still possible to use PDO and loaded instances (without IoC) after Iluminate\Foundation\Testing\TestCase::tearDown() has been called.
  • Any cached/static instances holding PDO will still prevent the database from being disconnected.

Option B: Reuse PDO instances between tests (provided in this PR)

Disadvantage:

  • The only issue I can think of if tests within a TestCase change database configuration between tests, since the PDO instance is loaded using cache it not going to fetch the specific configuration defined for the test. However, I don't see any reason why a developer would have this for the application tests, even for package development this would be a rare requirement.

To cover Option A, you can configure a base TestCase and set $disconnectDatabaseConnections property to true:

<?php

namespace Tests;

abstract class TestCase extends \Illuminate\Foundation\Testing\TestCase
{
    use CreatesApplication;

+   protected $disconnectDatabaseConnections = true;

}

crynobone avatar Dec 15 '23 03:12 crynobone

Related to #49373? Please comment there

mpyw avatar Dec 15 '23 05:12 mpyw

Since this applies to code that falls outside the scope of Suggested change , I would like to comment on it separately. (I'm not familiar with MySQL, so I'd like to comment only on PostgreSQL. But I think MySQL probably has similar features as well.)

https://github.com/laravel/framework/pull/49385#discussion_r1427684362

There is a risk that the transaction state and session settings will be carried over

There is some changeable state that persists while a session is open, whether inside or outside a transaction, and whether commit or rollback.

However, certain status also allow us to explicitly discard their state. (Some implementations may already exist. Sorry if there are.)

In order to preserve existing behavior, we should execute these (or other?) statements to return the session state to its initial state as much as possible.


UPDATE: 2023-12-16 9:52 JST

This problem can now be resolved by an option implemented after the first mention. Thank you!

To cover Option A, you can configure a base TestCase and set $disconnectDatabaseConnections property to true:

KentarouTakeda avatar Dec 15 '23 09:12 KentarouTakeda

Since PHPUnit is especially a long-running process when the application gets booted and terminated on each test the PDO may persist and eventually can cause max database connections.

What causes it to persist? How do you recreate this on a fresh Laravel application to hit the max connections error?

taylorotwell avatar Dec 16 '23 16:12 taylorotwell

What causes it to persist?

Mentioned in https://github.com/laravel/framework/discussions/49373#discussioncomment-7854320. It appears that a reference to the connection resource is being held in some code somewhere, but I don't know where. It may be Laravel code, but it may also be processed by PHPUnit.

How do you recreate this on a fresh Laravel application to hit the max connections error?

Mentioned in #49311. It happens by simply repeating a test case.

KentarouTakeda avatar Dec 17 '23 00:12 KentarouTakeda

What causes it to persist? How do you recreate this on a fresh Laravel application to hit the max connections error?

Before

CleanShot 2023-12-17 at 16 59 48

We assume using flush() and unset($app) would be enough to clear PDO reference but it doesn't seem so (which led me to believe that the documentation in PHP meant that we do need to explicitly set PDO instances to null.

After

CleanShot 2023-12-17 at 16 59 57

crynobone avatar Dec 17 '23 09:12 crynobone

We tried disconnecting the database connection in tearDown via #49327 but this caused a regression (reported via #49359) and proven that you can still interacts with PDO after unset($app)

crynobone avatar Dec 17 '23 09:12 crynobone

Sorry, but if the current behavior is a significant problem, why are we not seeing many more reports of this from basically every Laravel application in the world that has tests?

taylorotwell avatar Dec 17 '23 15:12 taylorotwell

@taylorotwell Possibly because everyone uses RefreshDatabase. But in some reason I prefer DatabaseTruncation, so I critically encountered the problem.

mpyw avatar Dec 17 '23 16:12 mpyw

@taylorotwell

Here are simple steps to reproduce this problem.

The following test code:

<?php

namespace Tests\Feature;

use Tests\TestCase;
use Illuminate\Foundation\Testing\DatabaseMigrations;
use Illuminate\Foundation\Testing\DatabaseTruncation;

class ConnTest extends TestCase
{
    use DatabaseMigrations;
    // or use DatabaseTruncation;
    public static function repeat(): array
    {
        return array_fill(0, 10, []);
    }

    /** @dataProvider repeat */
    public function test(): void
    {
        echo "waiting input...";
        ob_flush();
        fgets(STDIN);
        echo "input received\n";
        $this->assertTrue(true);
    }
}

run test:

php artisan test tests/Feature/ConnTest.php

The test will stop until the Enter key is pressed:

waiting input...
input received
waiting input...
input received
waiting input...

...(omit)

For MySQL, you can check the number of connections with the following query:

show status like 'Threads_connected';

If you check the number of connections while stopping the test, you will see that the number of connections increases as the test case progresses:

mysql> show status like 'Threads_connected';
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_connected | 2     |
+-------------------+-------+
1 row in set (0.00 sec)

mysql> show status like 'Threads_connected';
+-------------------+-------+
| Variable_name     | Value |
+-------------------+-------+
| Threads_connected | 3     |
+-------------------+-------+
1 row in set (0.00 sec)

...(omit)

SakiTakamachi avatar Dec 17 '23 16:12 SakiTakamachi

I found the reason for @taylorotwell's question.

In short, with the current implementation, whether the problem occurs or not depends on when PHP's circular reference collector is triggered.

It varies depending on the PHP settings and the memory capacity of the execution environment, so

  • The code shown by @rikvdh may not always work.
  • The problem that @mpyw actually encountered may not be reproduced depending on the environment.

In any case, the current implementation's behavior changes depending on the environment (or luck), and this pull request will resolve that.

I have prepared CI results in my repository that can confirm the above operation. Complete results can be found here.

connection-graph

This image visualizes the increase and decrease in the number of simultaneous connections by repeating the same test. $app->flush() is called every time, but the actual timing of disconnection is different each time.

Complete CI results also include cases where we explicitly call gc_collect_cycles(). Since the number of connections in this case is constant, we can see that the trigger for disconnection is GC.

KentarouTakeda avatar Dec 18 '23 05:12 KentarouTakeda

I'd like to chime in on this since it seems like it may heavily affect me.

The only issue I can think of if tests within a TestCase change database configuration between tests, since the PDO instance is loaded using cache it not going to fetch the specific configuration defined for the test. However, I don't see any reason why a developer would have this for the application tests, even for package development this would be a rare requirement.

The reason I have is because I run a multi-database multi-tenant application and sometimes I have some critical features that need the extra level of security to ensure that:

  1. Given 2 tenants (2 databases) and 1 user in each tenant
  2. When User A performs an action
  3. It reflects on Tenant's database without crossing/impacting the tenant boundary.

From reading the PR, I see Taylor's point. I've been using Laravel since 5.0 and I have never had any max connection issues. I believe many Laravel users don't face this issue at all. Making a significantly drastic change to how the framework handles database connections affecting every Laravel/Phpunit Users to fix an edge case without a way to keep the existing behavior seems like an opportunity to invite many more issues/bug reports than the current situation.

I'm all for reusing the same PDO connection across many tests which could improve performance and speed up tests, but I feel like users need to be able to better control this.

While we're on this subject, a paper cut issue I've had with Laravel for many years is precisely how DatabaseManager is tied with Config Repository, making it unable to work without global state changes that causes confusion.

image

image

If something like my personal desire could be implemented, then the disadvantage listed here would be way less relevant. From my understanding, the disadvantage listed is caused exactly by my "paper cut" mention that you need to manipulate a global state on Config Repository prior to establishing a connection with the DatabaseManager, otherwise any changes to the global Config Repository will not reflect on the DatabaseManager connections already established.

If we expose a establishUsing(string $name, array $config, bool $throwsIfAlreadyConnected = true) on the DatabaseManager in a way that we no longer rely on Config Repository, then it completely removes the surprise-factor when a Laravel user wants to hot swap connection configuration on the DatabaseManager without needing to interact with the global Repository Config.

Note: the method signature here is just a prototype. I don't really expect to have a $throwsIfAlreadyConnected parameter, my goal was more to express the desire allow Laravel users to decide whether to override existing connections on-the-fly or throw an exception if they try to override an existing connection without knowing.

This would also make it easier to use the Migrator class outside of Artisan and we could work out the ability to $migrator->runOn([$migrationsPath], $connectionName, $connectionConfiguration) without having to DB::purge() and then Config::set() (this is something we could easily accomplish on user land code without Laravel needing to get involved).

deleugpn avatar Dec 30 '23 14:12 deleugpn

@taylorotwell Possibly because everyone uses RefreshDatabase. But in some reason I prefer DatabaseTruncation, so I critically encountered the problem.

@mpyw If this problem mainly affects DatabaseTruncation, can't it be fixed inside DatabaseTruncation trait?

deleugpn avatar Dec 30 '23 18:12 deleugpn

I believe many Laravel users don't face this issue at all.

It all depends on the max connection configured locally and on CI, we did face this issue on Framework own tests a few years back: https://github.com/laravel/framework/pull/39877

I'm all for reusing the same PDO connection across many tests which could improve performance and speed up tests, but I feel like users need to be able to better control this.

Welcome any suggestion on this, we did add ability to disconnect the database connection via a property flag in this PR.

If this problem mainly affects DatabaseTruncation, can't it be fixed inside DatabaseTruncation trait?

If the approach is to disconnect all database during tearDown, this have been proposed and rejected, also potentially impacts performance (since we only disconnect database connectio via RefreshDatabase and DatabaseMigrations trait at the moment) with less control.

crynobone avatar Jan 02 '24 01:01 crynobone

Would it be simpler and less breaking (particularly for the multi-tenant scenario) to just call gc_collect_cycles periodically while running tests?

taylorotwell avatar Jan 07 '24 15:01 taylorotwell

If the approach is to disconnect all database during tearDown, this have been proposed and rejected, also potentially impacts performance (since we only disconnect database connectio via RefreshDatabase and DatabaseMigrations trait at the moment) with less control.

I guess my primary point is: I'm a user of the DatabaseTransactions trait and as such it seems I'm more unlikely to face this problem than users of the DatabaseTruncation as it was mentioned.

Perhaps the implemented solution to the problem can be more targeted at where the problem is and leave users that are outside the scope of it unaffected. The problem with the current proposal is that it completely hijacks the DatabaseConnectionFactory at a global level (OverrideProvidersForTesting), causing 100% of Laravel installation to be affected by this change regardless of whether they are subject to the reported bug or not.

deleugpn avatar Jan 20 '24 14:01 deleugpn

@crynobone

Would it be simpler and less breaking (particularly for the multi-tenant scenario) to just call gc_collect_cycles periodically while running tests?

Is this the answer?

mpyw avatar Feb 27 '24 23:02 mpyw