diff --git a/.allowed-licenses b/.allowed-licenses
new file mode 100644
index 0000000..11b8260
--- /dev/null
+++ b/.allowed-licenses
@@ -0,0 +1,7 @@
+- Apache-2.0
+- BSD-2-Clause
+- BSD-3-Clause
+- ISC
+- LGPL-3.0-or-later
+- MIT
+- OSL-3.0
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..d25ff78
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,47 @@
+root=true
+[*]
+charset=utf-8
+indent_style=space
+indent_size=4
+tab_width=4
+end_of_line=lf
+trim_trailing_whitespace=true
+insert_final_newline=true
+
+[*.{yaml,yml}]
+indent_size=2
+tab_width=2
+
+[*.{js,ts,jsx,tsx}]
+indent_size=2
+tab_width=2
+
+[*.hcl]
+indent_size=2
+tab_width=2
+
+[Makefile]
+indent_style=tab
+
+[Jenkinsfile]
+indent_size=2
+tab_width=2
+
+[*.sh]
+end_of_line=lf
+
+[*.md]
+trim_trailing_whitespace=false
+
+[*.{xml,xsd}]
+max_line_length=off
+indent_size=2
+tab_width=2
+
+[*.json]
+indent_size=2
+tab_width=2
+
+[*.neon]
+indent_size=2
+tab_width=2
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 0000000..a8d4041
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,16 @@
+# To get started with Dependabot version updates, you'll need to specify which
+# package ecosystems to update and where the package manifests are located.
+# Please see the documentation for all configuration options:
+# https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
+
+version: 2
+updates:
+ - package-ecosystem: "composer"
+ directory: "/"
+ schedule:
+ interval: "daily"
+ ignore:
+ - dependency-name: "*"
+ update-types: [ "version-update:semver-major" ]
+ - dependency-name: "symfony/*"
+ versions: ["6.x"]
diff --git a/.github/labeler.yml b/.github/labeler.yml
new file mode 100644
index 0000000..95f8fc5
--- /dev/null
+++ b/.github/labeler.yml
@@ -0,0 +1,3 @@
+not-automated:
+ - '**/**'
+ - '.github/**'
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
new file mode 100644
index 0000000..5f2719f
--- /dev/null
+++ b/.github/workflows/php.yml
@@ -0,0 +1,80 @@
+name: PHP Composer
+
+on:
+ push:
+ branches: [ master ]
+ pull_request:
+ branches: [ master ]
+
+permissions:
+ pull-requests: write
+ issues: write
+ repository-projects: write
+ contents: write
+
+jobs:
+ build:
+
+ runs-on: ubuntu-latest
+ strategy:
+ matrix:
+ php-version: [ '8.2', '8.3' ]
+ symfony-version: ['6.4.*', '7.0.*' ]
+ symfony-deprecations-helper: [ 'max[direct]=0&baselineFile=./tests/allowed.json' ]
+ grumphp-testsuite: [ 'no-analyse' ]
+ grumphp-flag: [ '-no-analyse' ]
+
+ name: "PHP: ${{ matrix.php-version }}, Symfony: ${{ matrix.symfony-version }}, GrumPHP: ${{ matrix.grumphp-testsuite }}, Composer: ${{ matrix.composer-flag }}"
+
+ steps:
+ - uses: actions/checkout@v2
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php-version }}
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate --strict
+
+ - name: Check PHP Version
+ run: php -v
+
+ - name: Symfony version # run every time except for dependencies with SF 5.4 (locked in composer)
+ if: ${{ matrix.grumphp-testsuite == 'no-analyse' }}
+ run: composer config extra.symfony.require ${{ matrix.symfony-version }}
+
+ - name: Composer update # run for everything except php 8.1 or SF 5.4 locked in composer.lock
+ if: ${{ matrix.grumphp-testsuite == 'no-analyse' }}
+ run: composer update ${{ matrix.composer-flag }} --prefer-dist --no-interaction
+
+ - name: Composer install # only run for locked dependencies with php 8.1 or SF 5.4 (locked in composer)
+ if: ${{ matrix.grumphp-testsuite == 'main' }}
+ run: composer install --prefer-dist --no-interaction
+
+ - name: Run static analysis (GrumPHP)
+ run: composer run-script grumphp${{ matrix.php-version }}${{ matrix.grumphp-flag }}
+
+# - name: Check vendor licenses
+# run: composer run-script lic-check
+
+ dependabot:
+ needs: [ build ]
+ runs-on: ubuntu-latest
+ if: ${{ github.actor == 'dependabot[bot]' }}
+ steps:
+ - name: Dependabot metadata
+ id: metadata
+ uses: dependabot/fetch-metadata@v1.1.1
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ - name: Approve a dependabot PR
+ run: gh pr review --approve "$PR_URL"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
+ - name: Enable auto-merge for Dependabot PRs
+ run: gh pr merge --auto --merge "$PR_URL"
+ env:
+ PR_URL: ${{github.event.pull_request.html_url}}
+ GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
diff --git a/.github/workflows/pr_labeller.yml b/.github/workflows/pr_labeller.yml
new file mode 100644
index 0000000..3313fb9
--- /dev/null
+++ b/.github/workflows/pr_labeller.yml
@@ -0,0 +1,13 @@
+name: Pull request labeler
+on:
+ pull_request:
+ branches: [ master ]
+jobs:
+ label:
+ runs-on: ubuntu-latest
+ if: ${{ github.actor != 'dependabot[bot]' }}
+ steps:
+ - uses: actions/labeler@v3
+ with:
+ repo-token: "${{ secrets.GITHUB_TOKEN }}"
+ sync-labels: true
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..190930c
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,11 @@
+vendor
+.php-cs-fixer.cache
+.phpunit.result.cache
+.phpunit.cache
+.phpcs-cache
+docker-compose.override.yml
+tests/Functional/app/data/*
+!tests/Functional/app/data/.gitkeep
+mutagen.yml.lock
+/.idea/
+composer.lock
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 0000000..0eb4d26
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,60 @@
+setRules([
+ '@PER-CS2.0' => true,
+ '@PHP81Migration' => true,
+ 'array_indentation' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+ 'blank_line_before_statement' => true,
+ 'braces_position' => ['classes_opening_brace' => 'next_line_unless_newline_at_signature_end'],
+ 'class_reference_name_casing' => false,
+ 'combine_consecutive_issets' => true,
+ 'combine_consecutive_unsets' => true,
+ 'compact_nullable_type_declaration' => true,
+ 'global_namespace_import' => [
+ 'import_classes' => true,
+ ],
+ 'linebreak_after_opening_tag' => true,
+ 'list_syntax' => ['syntax' => 'short'],
+ 'multiline_whitespace_before_semicolons' => ['strategy' => 'no_multi_line'],
+ 'native_constant_invocation' => [
+ 'fix_built_in' => false,
+ 'scope' => 'all',
+ ],
+ 'native_function_invocation' => [
+ 'include' => [],
+ 'exclude' => ['@all'],
+ ],
+ 'no_blank_lines_after_class_opening' => true,
+ 'no_null_property_initialization' => true,
+ 'no_useless_else' => true,
+ 'no_useless_return' => true,
+ 'nullable_type_declaration_for_default_null_value' => false,
+ 'ordered_class_elements' => true,
+ 'ordered_imports' => ['sort_algorithm' => 'alpha', 'imports_order' => ['class', 'function', 'const']],
+ 'no_unused_imports' => true,
+ 'phpdoc_order' => [
+ 'order' => ['param', 'return', 'throws'],
+ ],
+ 'phpdoc_types_order' => [
+ 'null_adjustment' => 'always_last',
+ ],
+ 'yoda_style' => [
+ 'equal' => false,
+ 'identical' => false,
+ 'less_and_greater' => false,
+ ],
+ ])
+ ->setRiskyAllowed(true)
+ ->setFinder(
+ PhpCsFixer\Finder::create()
+ ->exclude('vendor')
+ ->in(__DIR__ . '/src')
+ ->in(__DIR__ . '/tests')
+ );
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..0d9387d
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2018 K911
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..e0aa985
--- /dev/null
+++ b/README.md
@@ -0,0 +1,58 @@
+# Doctrine resettable entity manager bundle
+
+This bundle should be used with workloads where Symfony doesn't get initialized on each request, but stays in memory
+and the same instance handles multiple requests, one after another (e.g. when using
+[Swoole Bundle](https://github.com/symfony-swoole/swoole-bundle)).
+Another use case would be message queue consuming (e.g. Symfony messenger), where it is needed
+to clear (and possibly reset) the entity manager after processing a message.
+
+The best feature of this bundle is, that it wraps all configured entity managers
+into a `ResettableEntityManager` instance, which
+is able to reset the entity manager when it gets stuck on an exception.
+After each request the entity manager gets cleared or reset, if an exception occurred during request handling.
+
+Also another feature is, that on each request start the entity manager connection gets pinged, so the connection
+won't get closed after some period of time.
+
+## Instalation
+
+`composer require swoole-bundle/resetter-bundle`
+
+## SETUP
+
+```php
+// config/bundles.php
+return [
+ //...
+ \SwooleBundle\ResetterBundle\SwooleBundleResetterBundle::class => ['all' => true]
+ //...
+];
+```
+
+```yaml
+swoole_bundle_resetter:
+ exclude_from_processing:
+ # these entity managers won't be wrapped by the resettable entity manager:
+ entity_managers:
+ - readonly
+ # these dbal connections won't be assigned to the keep alive handler
+ dbal:
+ - readonly
+ # these redis cluster connections won't be assigned to the keep alive handler
+ redis_cluster:
+ - default
+ # default 0 - if set, the connection ping operation will be executed each X seconds
+ # (instead of at the beginning of each request)
+ ping_interval: 10
+ # default false - if set to true, the app will check if there is an active transaction
+ # in the processed connection, and it will rollback the transaction
+ check_active_transactions: true
+ # for reader writer connections, each has to be defined as 'reader' or 'writer' to be able for the symfony
+ # app to reconnect after db failover. currently only AWS Aurora is supported.
+ failover_connections:
+ default: writer
+ # redis clusters which need to be failed over should be registered here
+ # it's really important to set default timeouts to a low value, e.g. 2 seconds, so the app won't block for too long
+ redis_cluster_connections:
+ default: 'RedisCluster' # connection name (can be literally anything) => redis cluster service id
+```
diff --git a/bin/grumphp_hooks/environment_spinup b/bin/grumphp_hooks/environment_spinup
new file mode 100755
index 0000000..0d1c682
--- /dev/null
+++ b/bin/grumphp_hooks/environment_spinup
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+export PATH="/usr/local/bin:$PATH"
+
+#
+# Run the hook command.
+# Note: this will be replaced by the real command during copy.
+#
+DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
+
+if [[ -z `docker-compose ps -q resetter-bundle-php82` ]] || [[ -z `docker ps -q --no-trunc | grep $(docker-compose ps -q resetter-bundle-php82)` ]]; then
+ docker-compose up -d
+fi
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..a830b55
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,85 @@
+{
+ "name": "swoole-bundle/resetter-bundle",
+ "version": "1.0.0",
+ "homepage": "https://github.com/swoole-bundle/resetter-bundle",
+ "type": "symfony-bundle",
+ "description": "Symfony bundle with resetters for various connections, e.g. doctrine/dbal and phpredis.",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Martin Fris",
+ "email": "rasta@lj.sk",
+ "homepage": "https://github.com/Rastusik"
+ }
+ ],
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "require": {
+ "php": ">= 8.2",
+ "doctrine/dbal": "^4.0|^3.3",
+ "doctrine/doctrine-bundle": "^2.10",
+ "doctrine/orm": "^2.15|^3.0",
+ "symfony/config": "^6.4|^7.0",
+ "symfony/dependency-injection": "^6.4|^7.0",
+ "symfony/proxy-manager-bridge": "6.4|^7.0",
+ "symfony/yaml": "^6.4|^7.0"
+ },
+ "autoload": {
+ "psr-4": {
+ "SwooleBundle\\ResetterBundle\\": "src/"
+ }
+ },
+ "require-dev": {
+ "doctrine/annotations": "^2.0",
+ "friendsofphp/php-cs-fixer": "^3.13.2",
+ "nikic/php-parser": "^5.0",
+ "php-parallel-lint/php-parallel-lint": "^1.3",
+ "phpcompatibility/php-compatibility": "^9.1",
+ "phpmd/phpmd": "^2.8",
+ "phpro/grumphp-shim": "^2.10",
+ "phpstan/phpstan": "^1.0",
+ "phpstan/phpstan-webmozart-assert": "^1.0",
+ "phpunit/phpunit": "^10.2",
+ "psalm/phar": "^5.26",
+ "roave/security-advisories": "dev-master",
+ "slevomat/coding-standard": "^8.15",
+ "squizlabs/php_codesniffer": "^3.11",
+ "symfony/browser-kit": "^6.4|^7.0",
+ "symfony/flex": "^2.3",
+ "symfony/framework-bundle": "^6.4 || ^7.0",
+ "symfony/http-kernel": "^6.4 || ^7.0",
+ "symfony/phpunit-bridge": "^6.4 || ^7.0",
+ "symplify/config-transformer": "^12.0"
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "SwooleBundle\\ResetterBundle\\Tests\\": "tests/"
+ }
+ },
+ "config": {
+ "sort-packages": true,
+ "allow-plugins": {
+ "dealerdirect/phpcodesniffer-composer-installer": true,
+ "phpro/grumphp-shim": true,
+ "symfony/flex": true
+ }
+ },
+ "scripts": {
+ "grumphp8.2": "grumphp run --testsuite=php8.2",
+ "grumphp8.2-no-analyse": "grumphp run --testsuite=php8.2-no-analyse",
+ "grumphp8.3-no-analyse": "grumphp run --testsuite=php8.3-no-analyse",
+ "phpcs": "phpcs --standard=phpcs.xml",
+ "phpcbf": "phpcbf --standard=phpcs.xml --extensions=php --tab-width=4 -sp src tests",
+ "code-style:check": "php-cs-fixer --config=./.php-cs-fixer.dist.php fix --dry-run --diff --ansi --verbose src tests",
+ "code-style:fix": "php-cs-fixer --config=./.php-cs-fixer.dist.php fix --diff --ansi src tests",
+ "phpmd": "phpmd src text phpmd.xml",
+ "phpstan": "phpstan analyse src --level=max",
+ "phpunit": "phpunit",
+ "psalm": "psalm.phar"
+ },
+ "extra": {
+ "symfony": {
+ "require": "6.4.*"
+ }
+ }
+}
diff --git a/config/services.php b/config/services.php
new file mode 100644
index 0000000..8553889
--- /dev/null
+++ b/config/services.php
@@ -0,0 +1,83 @@
+services();
+
+ $services->defaults();
+
+ $services->set(ConnectionsHandler::class)
+ ->arg('$aliveKeepers', null);
+
+ $services->set(DBALPlatformAliveKeeper::class)
+ ->arg('$connections', null)
+ ->arg('$aliveKeepers', null);
+
+ $services->set(OptimizedDBALAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$decorated', null);
+
+ $services->set(PingingDBALAliveKeeper::class);
+
+ $services->set(TransactionDiscardingDBALAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$decorated', null)
+ ->arg('$logger', service('logger'))
+ ->tag('monolog.logger', ['channel' => 'resetter-bundle']);
+
+ $services->set(PassiveIgnoringDBALAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$decorated', null);
+
+ $services->set(RedisClusterPlatformAliveKeeper::class)
+ ->arg('$connections', null)
+ ->arg('$aliveKeepers', null);
+
+ $services->set(PingingRedisClusterAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$constructorArguments', null)
+ ->arg('$logger', service('logger'))
+ ->tag('monolog.logger', ['channel' => 'resetter-bundle']);
+
+ $services->set(PassiveIgnoringRedisClusterAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$decorated', null);
+
+ $services->set(FailoverAwareDBALAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$logger', service('logger'))
+ ->arg('$connectionType', null)
+ ->tag('monolog.logger', ['channel' => 'resetter-bundle']);
+
+ $services->set(OptimizedRedisClusterAliveKeeper::class)
+ ->abstract(true)
+ ->arg('$decorated', null);
+
+ $services->set(Initializers::class)
+ ->args([
+ tagged_iterator('swoole_bundle_resetter.app_initializer'),
+ ])
+ ->tag('kernel.event_listener', [
+ 'event' => 'kernel.request',
+ 'method' => 'initialize',
+ 'priority' => 1000000,
+ ]);
+};
diff --git a/docker-compose.override.yml.osx b/docker-compose.override.yml.osx
new file mode 100644
index 0000000..0cf02da
--- /dev/null
+++ b/docker-compose.override.yml.osx
@@ -0,0 +1,17 @@
+# https://medium.com/@marickvantuil/speed-up-docker-for-mac-with-mutagen-14c2a2c9cba7
+# mutagen sync create --name=resettableembundle -i .idea ~/DEV/doctrine-resettable-entity-manager docker://root@resetter-bundle-php7/srv/www
+# in PHPSTORM use a custom Docker container for quality tools with this volume mapping:
+# server-ddd-bundle_ddd_bundle_volume:/srv/www
+# until it is possible to use PHP from docker-compose in quality tools again
+volumes:
+ php_volume: {}
+
+services:
+ # PHP
+ resetter-bundle-php82:
+ volumes:
+ - php_volume:/srv/www
+
+ resetter-bundle-php83:
+ volumes:
+ - php_volume:/srv/www
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..68e3559
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,17 @@
+services:
+ # PHP
+ resetter-bundle-php82:
+ container_name: resetter-bundle-php82
+ build: ./docker/php82
+ volumes:
+ - .:/srv/www:delegated
+ environment:
+ - XDEBUG_CONFIG=idekey=PHPSTORM
+
+ resetter-bundle-php83:
+ container_name: resetter-bundle-php83
+ build: ./docker/php83
+ volumes:
+ - .:/srv/www:delegated
+ environment:
+ - XDEBUG_CONFIG=idekey=PHPSTORM
diff --git a/docker/php81/Dockerfile b/docker/php81/Dockerfile
new file mode 100644
index 0000000..fb99dd3
--- /dev/null
+++ b/docker/php81/Dockerfile
@@ -0,0 +1,20 @@
+FROM php:8.1-fpm
+
+RUN apt-get update && apt-get install -y git-core zlib1g-dev libzip-dev zip unzip
+RUN docker-php-ext-install zip pdo_mysql
+
+RUN pecl install xdebug redis && \
+ docker-php-ext-enable redis
+
+RUN echo 'xdebug.file_link_format="phpstorm://open?url=file://%%f&line=%%l"' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.mode=develop" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.max_nesting_level=10000" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.remote_handler=dbgp" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
+
+RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \
+ php -r "if (hash_file('SHA384', 'composer-setup.php') === trim(file_get_contents('https://composer.github.io/installer.sig'))) { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" && \
+ php composer-setup.php && \
+ mv composer.phar /usr/local/bin/composer && \
+ unlink composer-setup.php
+
+WORKDIR /srv/www
diff --git a/docker/php82/Dockerfile b/docker/php82/Dockerfile
new file mode 100644
index 0000000..1b98a70
--- /dev/null
+++ b/docker/php82/Dockerfile
@@ -0,0 +1,20 @@
+FROM php:8.2-fpm
+
+RUN apt-get update && apt-get install -y git-core zlib1g-dev libzip-dev zip unzip
+RUN docker-php-ext-install zip pdo_mysql
+
+RUN pecl install xdebug redis && \
+ docker-php-ext-enable redis
+
+RUN echo 'xdebug.file_link_format="phpstorm://open?url=file://%%f&line=%%l"' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.mode=develop" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.max_nesting_level=10000" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.remote_handler=dbgp" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
+
+RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \
+ php -r "if (hash_file('SHA384', 'composer-setup.php') === trim(file_get_contents('https://composer.github.io/installer.sig'))) { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" && \
+ php composer-setup.php && \
+ mv composer.phar /usr/local/bin/composer && \
+ unlink composer-setup.php
+
+WORKDIR /srv/www
diff --git a/docker/php83/Dockerfile b/docker/php83/Dockerfile
new file mode 100644
index 0000000..586ac78
--- /dev/null
+++ b/docker/php83/Dockerfile
@@ -0,0 +1,20 @@
+FROM php:8.3-fpm
+
+RUN apt-get update && apt-get install -y git-core zlib1g-dev libzip-dev zip unzip
+RUN docker-php-ext-install zip pdo_mysql
+
+RUN pecl install xdebug redis && \
+ docker-php-ext-enable redis
+
+RUN echo 'xdebug.file_link_format="phpstorm://open?url=file://%%f&line=%%l"' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.mode=develop" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.max_nesting_level=10000" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini && \
+ echo "xdebug.remote_handler=dbgp" >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
+
+RUN php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" && \
+ php -r "if (hash_file('SHA384', 'composer-setup.php') === trim(file_get_contents('https://composer.github.io/installer.sig'))) { echo 'Installer verified'; } else { echo 'Installer corrupt'; unlink('composer-setup.php'); } echo PHP_EOL;" && \
+ php composer-setup.php && \
+ mv composer.phar /usr/local/bin/composer && \
+ unlink composer-setup.php
+
+WORKDIR /srv/www
diff --git a/grumphp.yml b/grumphp.yml
new file mode 100644
index 0000000..218643c
--- /dev/null
+++ b/grumphp.yml
@@ -0,0 +1,79 @@
+grumphp:
+ git_hook_variables:
+ EXEC_GRUMPHP_COMMAND: './bin/grumphp_hooks/environment_spinup && docker exec -t resetter-bundle-php82'
+ stop_on_failure: true
+ process_timeout: 600
+ ignore_unstaged_changes: true
+ testsuites:
+ php8.2:
+ tasks:
+ - phpcs
+ - phpmd
+ - phpcsfixer2
+ - phpparser
+ - phplint
+ - phpunit
+ - phpstan
+ - psalm
+ php8.2-no-analyse:
+ tasks:
+ - phpparser
+ - phplint
+ - phpunit
+ php8.3-no-analyse:
+ tasks:
+ - phpparser
+ - phplint
+ - phpunit
+ tasks:
+ phpcs:
+ standard: 'phpcs.xml'
+ tab_width: 4
+ whitelist_patterns: []
+ encoding: utf-8
+ ignore_patterns: []
+ sniffs: []
+ triggered_by: [php]
+ phpmd:
+ ruleset: ['phpmd.xml']
+ phpcsfixer2:
+ cache_file: '.php-cs-fixer.cache'
+ allow_risky: true
+ config: '.php-cs-fixer.dist.php'
+ using_cache: true
+ config_contains_finder: true
+ verbose: false
+ diff: true
+ triggered_by: ['php']
+ phpparser:
+ ignore_patterns:
+ - tests/
+ kind: php7
+ visitors:
+ declare_strict_types: ~
+ no_exit_statements: ~
+ never_use_else: ~
+ forbidden_function_calls:
+ blacklist:
+ - 'var_dump'
+ forbidden_static_method_calls:
+ blacklist:
+ - 'Dumper::dump'
+ triggered_by: [php]
+ phplint: ~
+ phpunit: ~
+ phpstan:
+ autoload_file: ~
+ configuration: 'phpstan.neon'
+ level: max
+ ignore_patterns:
+ - tests/
+ triggered_by: ['php']
+ psalm:
+ config: psalm.xml
+ ignore_patterns:
+ - tests
+ no_cache: false
+ report: ~
+ triggered_by: ['php']
+ show_info: true
diff --git a/mutagen.yml b/mutagen.yml
new file mode 100644
index 0000000..e617e49
--- /dev/null
+++ b/mutagen.yml
@@ -0,0 +1,13 @@
+sync:
+ defaults:
+ ignore:
+ vcs: false
+ paths:
+ - .DS_Store # macOS files
+ - .idea
+ permissions:
+ defaultFileMode: 644
+ defaultDirectoryMode: 755
+ resettableembundle:
+ alpha: "."
+ beta: "docker://root@resetter-bundle-php82/srv/www"
diff --git a/phpcs.xml b/phpcs.xml
new file mode 100644
index 0000000..77d95e6
--- /dev/null
+++ b/phpcs.xml
@@ -0,0 +1,140 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests/*
+ src/*
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ tests/*
+
+
+
+ src/ORM/ResettableEntityManager.php
+
+
+
+ tests/Functional/app/HttpRequestLifecycleTest/Entity/*
+ tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/*
+ tests/Unit/Redis/Cluster/Connection/RedisClusterSpy.php
+ tests/Unit/Helper/ProxyConnectionMock.php
+
+
+ src
+ tests
+ /src/Bridge/Monolog/StreamHandler\.php$
+
+
diff --git a/phpmd.xml b/phpmd.xml
new file mode 100644
index 0000000..b7587f5
--- /dev/null
+++ b/phpmd.xml
@@ -0,0 +1,68 @@
+
+
+
+ Custom rule set for Bundles
+
+
+ tests/
+ DependencyInjection/
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+ Detects when a field, formal or local variable is declared with a long name.
+
+
+
+
+
+
+
+ 3
+
+
+
+
+
+
+
+
+
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..2be44b4
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,8 @@
+parameters:
+ level: max
+ ignoreErrors:
+
+ excludePaths:
+ - src/DependencyInjection/*
+ - src/ORM/ResettableEntityManager.php
+ - tests/*
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..0a5679a
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+ tests
+
+
+
+
+ ./
+
+
+ ./src/Resources
+ ./tests
+ ./vendor
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 0000000..a0c57a1
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Connection/ConnectionsHandler.php b/src/Connection/ConnectionsHandler.php
new file mode 100644
index 0000000..0e0c6fd
--- /dev/null
+++ b/src/Connection/ConnectionsHandler.php
@@ -0,0 +1,28 @@
+ $aliveKeepers
+ */
+ public function __construct(
+ private readonly array $aliveKeepers,
+ ) {}
+
+ /**
+ * @throws Exception
+ */
+ public function initialize(): void
+ {
+ foreach ($this->aliveKeepers as $aliveKeeper) {
+ $aliveKeeper->keepAlive();
+ }
+ }
+}
diff --git a/src/Connection/PlatformAliveKeeper.php b/src/Connection/PlatformAliveKeeper.php
new file mode 100644
index 0000000..0578773
--- /dev/null
+++ b/src/Connection/PlatformAliveKeeper.php
@@ -0,0 +1,10 @@
+ $connections
+ * @param array $aliveKeepers
+ */
+ public function __construct(
+ private array $connections,
+ private array $aliveKeepers,
+ ) {}
+
+ public function keepAlive(): void
+ {
+ foreach ($this->aliveKeepers as $connectionName => $aliveKeeper) {
+ if (!isset($this->connections[$connectionName])) {
+ throw new RuntimeException(
+ sprintf('Connection "%s" is missing.', $connectionName),
+ );
+ }
+
+ $connection = $this->connections[$connectionName];
+ $aliveKeeper->keepAlive($connection, $connectionName);
+ }
+ }
+
+ public function addAliveKeeper(string $connectionName, Connection $connection, DBALAliveKeeper $aliveKeeper): void
+ {
+ $this->connections[$connectionName] = $connection;
+ $this->aliveKeepers[$connectionName] = $aliveKeeper;
+ }
+
+ public function removeAliveKeeper(string $connectionName): void
+ {
+ unset($this->connections[$connectionName], $this->aliveKeepers[$connectionName]);
+ }
+}
diff --git a/src/DBAL/Connection/FailoverAware/ConnectionType.php b/src/DBAL/Connection/FailoverAware/ConnectionType.php
new file mode 100644
index 0000000..33780a5
--- /dev/null
+++ b/src/DBAL/Connection/FailoverAware/ConnectionType.php
@@ -0,0 +1,37 @@
+type === self::WRITER;
+ }
+}
diff --git a/src/DBAL/Connection/FailoverAware/FailoverAwareDBALAliveKeeper.php b/src/DBAL/Connection/FailoverAware/FailoverAwareDBALAliveKeeper.php
new file mode 100644
index 0000000..5845356
--- /dev/null
+++ b/src/DBAL/Connection/FailoverAware/FailoverAwareDBALAliveKeeper.php
@@ -0,0 +1,78 @@
+connectionType = ConnectionType::create($connectionType);
+ }
+
+ /**
+ * @throws Exception
+ */
+ //phpcs:ignore SlevomatCodingStandard.Complexity.Cognitive.ComplexityTooHigh
+ public function keepAlive(Connection $connection, string $connectionName): void
+ {
+ try {
+ if (!$this->isProperConnection($connection)) {
+ $logLevel = $this->connectionType->isWriter() ? LogLevel::ALERT : LogLevel::WARNING;
+ $this->logger->log(
+ $logLevel,
+ sprintf("Failover reconnect for connection '%s'", $connectionName),
+ );
+ $this->reconnect($connection);
+ }
+ } catch (DriverException $e) {
+ $this->logger->info(
+ sprintf("Exceptional reconnect for DBAL connection '%s'", $connectionName),
+ ['exception' => $e],
+ );
+
+ try {
+ $this->reconnect($connection);
+ } catch (DriverException) { //phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
+ // this is usual reconnect
+ }
+ }
+ }
+
+ private function reconnect(Connection $connection): void
+ {
+ $connection->close();
+ $connection->getNativeConnection();
+ }
+
+ /**
+ * returns true if the connection is expected to be writable and innodb_read_only is set to 0
+ * or if the connection is not expected to be writable and innodb_read_only is set to 1
+ * these flags were only tested on AWS Aurora RDS
+ *
+ * @throws DriverException
+ */
+ private function isProperConnection(Connection $connection): bool
+ {
+ $stmt = $connection->executeQuery('SELECT @@global.innodb_read_only;');
+ //phpcs:ignore Squiz.PHP.DisallowComparisonAssignment.AssignedComparison
+ $currentConnectionIsWriter = (bool) $stmt->fetchOne() === false;
+
+ return $this->connectionType->isWriter() === $currentConnectionIsWriter;
+ }
+}
diff --git a/src/DBAL/Connection/OptimizedDBALAliveKeeper.php b/src/DBAL/Connection/OptimizedDBALAliveKeeper.php
new file mode 100644
index 0000000..10a4ee3
--- /dev/null
+++ b/src/DBAL/Connection/OptimizedDBALAliveKeeper.php
@@ -0,0 +1,49 @@
+lastPingAt = 0;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function keepAlive(Connection $connection, string $connectionName): void
+ {
+ if (!$this->isPingNeeded()) {
+ return;
+ }
+
+ $this->decorated->keepAlive($connection, $connectionName);
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function isPingNeeded(): bool
+ {
+ $lastPingAt = $this->lastPingAt;
+ $now = time();
+ $this->lastPingAt = $now;
+
+ return $now - $lastPingAt >= $this->pingIntervalInSeconds;
+ }
+}
diff --git a/src/DBAL/Connection/PassiveIgnoringDBALAliveKeeper.php b/src/DBAL/Connection/PassiveIgnoringDBALAliveKeeper.php
new file mode 100644
index 0000000..4c657f8
--- /dev/null
+++ b/src/DBAL/Connection/PassiveIgnoringDBALAliveKeeper.php
@@ -0,0 +1,32 @@
+isProxyInitialized()) {
+ return;
+ }
+
+ if (!$connection->isConnected()) {
+ return;
+ }
+
+ $this->decorated->keepAlive($connection, $connectionName);
+ }
+}
diff --git a/src/DBAL/Connection/PingingDBALAliveKeeper.php b/src/DBAL/Connection/PingingDBALAliveKeeper.php
new file mode 100644
index 0000000..e588f20
--- /dev/null
+++ b/src/DBAL/Connection/PingingDBALAliveKeeper.php
@@ -0,0 +1,29 @@
+getDatabasePlatform()->getDummySelectSQL();
+
+ try {
+ $connection->executeQuery($query);
+ } catch (ConnectionLost) {
+ $connection->close();
+ $connection->getNativeConnection();
+ }
+ }
+}
diff --git a/src/DBAL/Connection/TransactionDiscardingDBALAliveKeeper.php b/src/DBAL/Connection/TransactionDiscardingDBALAliveKeeper.php
new file mode 100644
index 0000000..b76032a
--- /dev/null
+++ b/src/DBAL/Connection/TransactionDiscardingDBALAliveKeeper.php
@@ -0,0 +1,47 @@
+isTransactionActive()) {
+ try {
+ $this->logger->error(
+ sprintf(
+ 'Connection "%s" needed to discard active transaction while running keep-alive routine.',
+ $connectionName,
+ ),
+ );
+ $connection->rollBack();
+ } catch (Throwable $e) {
+ $this->logger->error(
+ sprintf(
+ 'An error occurred while discarding active transaction in connection "%s".',
+ $connectionName,
+ ),
+ ['exception' => $e],
+ );
+ }
+ }
+
+ $this->decorated->keepAlive($connection, $connectionName);
+ }
+}
diff --git a/src/DependencyInjection/CompilerPass/AliveKeeperPass.php b/src/DependencyInjection/CompilerPass/AliveKeeperPass.php
new file mode 100644
index 0000000..ac01e37
--- /dev/null
+++ b/src/DependencyInjection/CompilerPass/AliveKeeperPass.php
@@ -0,0 +1,280 @@
+hasParameter(self::FAILOVER_CONNECTIONS_PARAM_NAME)) {
+ return;
+ }
+
+ $aliveKeepers = $this->createPlatformAliveKeepers($container);
+
+ $connectionsHandlerDef = $container->findDefinition(ConnectionsHandler::class);
+ $connectionsHandlerDef->setArgument('$aliveKeepers', $aliveKeepers);
+ $connectionsHandlerDef->addTag('swoole_bundle_resetter.app_initializer');
+ }
+
+ /**
+ * @return array
+ */
+ private function createPlatformAliveKeepers(ContainerBuilder $container): array
+ {
+ $aliveKeepers = [];
+ $dbalAliveKeeper = $this->createDBALPlatformAliveKeeper($container);
+
+ if ($dbalAliveKeeper !== null) {
+ $aliveKeepers[] = $dbalAliveKeeper;
+ }
+
+ $redisClusterAliveKeeper = $this->createRedisClusterPlatformAliveKeeper($container);
+
+ if ($redisClusterAliveKeeper !== null) {
+ $aliveKeepers[] = $redisClusterAliveKeeper;
+ }
+
+ return $aliveKeepers;
+ }
+
+ private function createDBALPlatformAliveKeeper(ContainerBuilder $container): ?Reference
+ {
+ $aliveKeepers = $this->createDBALAliveKeepers($container);
+
+ if (count($aliveKeepers) === 0) {
+ return null;
+ }
+
+ // @var array $connections
+ $connections = $container->getParameter('doctrine.connections');
+ $connectionRefs = [];
+
+ foreach ($connections as $connectionName => $connectionSvcId) {
+ $connectionRefs[$connectionName] = new Reference($connectionSvcId);
+ }
+
+ $aliveKeeperDef = $container->findDefinition(DBALPlatformAliveKeeper::class);
+ $aliveKeeperDef->setArgument('$connections', $connectionRefs);
+ $aliveKeeperDef->setArgument('$aliveKeepers', $aliveKeepers);
+
+ return new Reference(DBALPlatformAliveKeeper::class);
+ }
+
+ private function createRedisClusterPlatformAliveKeeper(ContainerBuilder $container): ?Reference
+ {
+ $connections = $this->createRedisClusterConnectionReferences($container);
+
+ if (count($connections) === 0) {
+ return null;
+ }
+
+ $aliveKeepers = $this->createRedisClusterAliveKeepers($container);
+
+ if (count($aliveKeepers) === 0) {
+ return null;
+ }
+
+ $aliveKeeperDef = $container->findDefinition(RedisClusterPlatformAliveKeeper::class);
+ $aliveKeeperDef->setArgument('$connections', $connections);
+ $aliveKeeperDef->setArgument('$aliveKeepers', $aliveKeepers);
+
+ return new Reference(RedisClusterPlatformAliveKeeper::class);
+ }
+
+ /**
+ * @return array
+ */
+ private function createDBALAliveKeepers(ContainerBuilder $container): array
+ {
+ // @var array $connections
+ $connections = $container->getParameter('doctrine.connections');
+ // @var array $failoverConnections
+
+ $failoverConnections = $container->getParameter(self::FAILOVER_CONNECTIONS_PARAM_NAME);
+ $pingInterval = (int) $container->hasParameter(Parameters::PING_INTERVAL)
+ ? $container->getParameter(Parameters::PING_INTERVAL)
+ : 0;
+ $checkActiveTransactions = (bool) $container->hasParameter(Parameters::CHECK_ACTIVE_TRANSACTIONS)
+ ? $container->getParameter(Parameters::CHECK_ACTIVE_TRANSACTIONS)
+ : false;
+ // @var array $excluded
+
+ $excluded = $container->getParameter(Parameters::EXCLUDED_FROM_PROCESSING_DBAL_CONNECTIONS);
+ $aliveKeepers = [];
+
+ foreach (array_keys($connections) as $connectionName) {
+ if (in_array($connectionName, $excluded, true)) {
+ continue;
+ }
+
+ $aliveKeeperSvcId = sprintf(
+ 'swoole_bundle_resetter.alive_keeper.dbal.%s',
+ $connectionName,
+ );
+ $container->setDefinition(
+ $aliveKeeperSvcId,
+ $this->getAliveKeeperDefinition($container, $connectionName, $failoverConnections),
+ );
+
+ if ($checkActiveTransactions) {
+ $decoratorAliveKeeperSvcId = sprintf(
+ '%s_%s',
+ TransactionDiscardingDBALAliveKeeper::class,
+ $connectionName,
+ );
+ $decoratedAliveKeeperSvcId = sprintf('%s.inner', $decoratorAliveKeeperSvcId);
+ $transDiscardingDef = new ChildDefinition(TransactionDiscardingDBALAliveKeeper::class);
+ $transDiscardingDef->setDecoratedService($aliveKeeperSvcId, $decoratedAliveKeeperSvcId, 1);
+ $transDiscardingDef->setArgument('$decorated', new Reference($decoratedAliveKeeperSvcId));
+ $container->setDefinition($decoratorAliveKeeperSvcId, $transDiscardingDef);
+ }
+
+ $passiveDecoratorAliveKeeperSvcId = sprintf(
+ '%s_%s',
+ PassiveIgnoringDBALAliveKeeper::class,
+ $connectionName,
+ );
+
+ $ignorePassiveAliveKeeperSvcId = sprintf('%s.inner', $passiveDecoratorAliveKeeperSvcId);
+ $passiveIgnoringDef = new ChildDefinition(PassiveIgnoringDBALAliveKeeper::class);
+ $passiveIgnoringDef->setDecoratedService($aliveKeeperSvcId, $ignorePassiveAliveKeeperSvcId);
+ $passiveIgnoringDef->setArgument('$decorated', new Reference($ignorePassiveAliveKeeperSvcId));
+ $container->setDefinition($passiveDecoratorAliveKeeperSvcId, $passiveIgnoringDef);
+
+ $aliveKeepers[$connectionName] = new Reference($aliveKeeperSvcId);
+
+ if ($pingInterval === 0) {
+ continue;
+ }
+
+ $optDecoratorAliveKeeperSvcId = sprintf('%s_%s', OptimizedDBALAliveKeeper::class, $connectionName);
+ $optDecoratedAliveKeeperSvcId = sprintf('%s.inner', $optDecoratorAliveKeeperSvcId);
+ $optimisedKeeperDef = new ChildDefinition(OptimizedDBALAliveKeeper::class);
+ $optimisedKeeperDef->setDecoratedService($aliveKeeperSvcId, $optDecoratedAliveKeeperSvcId, 2);
+ $optimisedKeeperDef->setArgument('$decorated', new Reference($optDecoratedAliveKeeperSvcId));
+ $optimisedKeeperDef->setArgument('$pingIntervalInSeconds', $pingInterval);
+ $container->setDefinition($optDecoratorAliveKeeperSvcId, $optimisedKeeperDef);
+ }
+
+ return $aliveKeepers;
+ }
+
+ /**
+ * @param array $failoverConnections
+ */
+ private function getAliveKeeperDefinition(
+ ContainerBuilder $container,
+ string $connectionName,
+ array $failoverConnections,
+ ): Reference | Definition {
+ if (!isset($failoverConnections[$connectionName])) {
+ return $container->findDefinition(PingingDBALAliveKeeper::class);
+ }
+
+ $aliveKeeper = new ChildDefinition(FailoverAwareDBALAliveKeeper::class);
+ $aliveKeeper->setArgument('$connectionType', $failoverConnections[$connectionName]);
+
+ return $aliveKeeper;
+ }
+
+ /**
+ * @return array
+ */
+ private function createRedisClusterConnectionReferences(ContainerBuilder $container): array
+ {
+ // @var array $clusterConnections
+
+ $clusterConnections = $container->getParameter(self::REDIS_CLUSTER_CONNECTIONS_PARAM_NAME);
+
+ return array_map(
+ static fn(string $connectionSvcId): Reference => new Reference($connectionSvcId),
+ $clusterConnections,
+ );
+ }
+
+ /**
+ * @return array
+ */
+ private function createRedisClusterAliveKeepers(ContainerBuilder $container): array
+ {
+ // @var array $clusterConnections
+
+ $clusterConnections = $container->getParameter(self::REDIS_CLUSTER_CONNECTIONS_PARAM_NAME);
+ $pingInterval = (int) $container->hasParameter(Parameters::PING_INTERVAL)
+ ? $container->getParameter(Parameters::PING_INTERVAL)
+ : 0;
+ // @var array $excluded
+
+ $excluded = $container->getParameter(Parameters::EXCLUDED_FROM_PROCESSING_REDIS_CLUSTER_CONNECTIONS);
+ $aliveKeepers = [];
+
+ foreach ($clusterConnections as $connectionName => $clusterSvcId) {
+ if (in_array($connectionName, $excluded, true)) {
+ continue;
+ }
+
+ $clusterDef = $container->findDefinition($clusterSvcId);
+ $aliveKeeper = new ChildDefinition(PingingRedisClusterAliveKeeper::class);
+ $aliveKeeper->setArgument('$constructorArguments', array_values($clusterDef->getArguments()));
+ $aliveKeeperSvcId = sprintf(
+ 'swoole_bundle_resetter.alive_keeper.redis_cluster.%s',
+ $connectionName,
+ );
+ $container->setDefinition($aliveKeeperSvcId, $aliveKeeper);
+
+ $passiveDecoratorAliveKeeperSvcId = sprintf(
+ '%s_%s',
+ PassiveIgnoringRedisClusterAliveKeeper::class,
+ $connectionName,
+ );
+
+ $ignorePassiveAliveKeeperSvcId = sprintf('%s.inner', $passiveDecoratorAliveKeeperSvcId);
+ $passiveIgnoringDef = new ChildDefinition(PassiveIgnoringRedisClusterAliveKeeper::class);
+ $passiveIgnoringDef->setDecoratedService($aliveKeeperSvcId, $ignorePassiveAliveKeeperSvcId);
+ $passiveIgnoringDef->setArgument('$decorated', new Reference($ignorePassiveAliveKeeperSvcId));
+ $container->setDefinition($passiveDecoratorAliveKeeperSvcId, $passiveIgnoringDef);
+
+ $aliveKeepers[$connectionName] = new Reference($aliveKeeperSvcId);
+
+ if ($pingInterval === 0) {
+ continue;
+ }
+
+ $optDecoratorAliveKeeperSvcId = sprintf('%s_%s', OptimizedRedisClusterAliveKeeper::class, $connectionName);
+ $optDecoratedAliveKeeperSvcId = sprintf('%s.inner', $optDecoratorAliveKeeperSvcId);
+ $optimisedKeeperDef = new ChildDefinition(OptimizedRedisClusterAliveKeeper::class);
+ $optimisedKeeperDef->setDecoratedService($aliveKeeperSvcId, $optDecoratedAliveKeeperSvcId, 2);
+ $optimisedKeeperDef->setArgument('$decorated', new Reference($optDecoratedAliveKeeperSvcId));
+ $optimisedKeeperDef->setArgument('$pingIntervalInSeconds', $pingInterval);
+ $container->setDefinition($optDecoratorAliveKeeperSvcId, $optimisedKeeperDef);
+ }
+
+ return $aliveKeepers;
+ }
+}
diff --git a/src/DependencyInjection/CompilerPass/EntityManagerDecoratorPass.php b/src/DependencyInjection/CompilerPass/EntityManagerDecoratorPass.php
new file mode 100644
index 0000000..a883f2c
--- /dev/null
+++ b/src/DependencyInjection/CompilerPass/EntityManagerDecoratorPass.php
@@ -0,0 +1,52 @@
+ $entityManagers
+
+ $entityManagers = $container->getParameter('doctrine.entity_managers');
+ // @var array $excluded
+
+ $excluded = $container->getParameter(Parameters::EXCLUDED_FROM_PROCESSING_ENTITY_MANAGERS);
+ $resettableEntityManagers = [];
+
+ foreach ($entityManagers as $name => $id) {
+ if (in_array($name, $excluded, true)) {
+ continue;
+ }
+
+ $emDefinition = $container->findDefinition($id);
+ $newId = $id . '_wrapped';
+ $configArg = $emDefinition->getArgument(1);
+
+ $decoratorDef = new Definition(ResettableEntityManager::class, [
+ '$configuration' => $configArg,
+ '$decoratedName' => $name,
+ '$doctrineRegistry' => new Reference('doctrine'),
+ '$wrapped' => new Reference($newId),
+ ]);
+ $decoratorDef->setPublic(true);
+
+ $entityManagers[$name] = $newId;
+ $resettableEntityManagers[$name] = new Reference($id);
+ $container->setDefinition($id, $decoratorDef);
+ $container->setDefinition($newId, $emDefinition);
+ }
+
+ $container->setParameter('doctrine.entity_managers', $entityManagers);
+ }
+}
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
new file mode 100644
index 0000000..2183a34
--- /dev/null
+++ b/src/DependencyInjection/Configuration.php
@@ -0,0 +1,128 @@
+getRootNode();
+ $rootNode->children()
+ ->arrayNode('exclude_from_processing')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->arrayNode('entity_managers')
+ ->info('Entity manager names excluded from processing.')
+ ->scalarPrototype()->end()
+ ->defaultValue([])
+ ->validate()
+ ->always(static function ($emNames) {
+ $validEmNames = [];
+
+ foreach ((array) $emNames as $emName) {
+ $emName = trim((string) $emName);
+ $validEmNames[] = $emName;
+ }
+
+ return $validEmNames;
+ })
+ ->end()
+ ->end()
+ ->arrayNode('connections')
+ ->addDefaultsIfNotSet()
+ ->children()
+ ->arrayNode('dbal')
+ ->info('DBAL connection names excluded from processing.')
+ ->scalarPrototype()->end()
+ ->defaultValue([])
+ ->validate()
+ ->always(static function ($connectionNames) {
+ $validNames = [];
+
+ foreach ((array) $connectionNames as $connectionName) {
+ $connectionName = trim((string) $connectionName);
+ $validNames[] = $connectionName;
+ }
+
+ return $validNames;
+ })
+ ->end()
+ ->end()
+ ->arrayNode('redis_cluster')
+ ->info('RedisCluster connection names excluded from processing.')
+ ->scalarPrototype()->end()
+ ->defaultValue([])
+ ->validate()
+ ->always(static function ($connectionNames) {
+ $validNames = [];
+
+ foreach ((array) $connectionNames as $connectionName) {
+ $connectionName = trim((string) $connectionName);
+ $validNames[] = $connectionName;
+ }
+
+ return $validNames;
+ })
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->end()
+ ->scalarNode('ping_interval')
+ ->defaultFalse()
+ ->end()
+ ->booleanNode('check_active_transactions')
+ ->defaultFalse()
+ ->end()
+ ->variableNode('failover_connections')
+ ->info('Master slave connections reader/writer configuration.')
+ ->defaultValue([])
+ ->validate()
+ ->always(static function ($connections): array {
+ $validConnections = [];
+
+ foreach ((array) $connections as $connectionName => $connectionType) {
+ $connectionName = trim((string) $connectionName);
+ $connectionType = strtolower(trim((string) $connectionType));
+
+ $validConnections[$connectionName] = $connectionType;
+ }
+
+ return $validConnections;
+ })
+ ->end()
+ ->end() // end failover_connections
+ ->variableNode('redis_cluster_connections')
+ ->info('Redis cluster connections for alive keeping.')
+ ->defaultValue([])
+ ->validate()
+ ->always(static function (array $connections): array {
+ $validConnections = [];
+
+ foreach ($connections as $connectionName => $connectionValue) {
+ $connectionName = trim((string) $connectionName);
+ $connectionValue = trim((string) $connectionValue);
+
+ if ($connectionName === '' || $connectionValue === '') {
+ continue;
+ }
+
+ $validConnections[$connectionName] = $connectionValue;
+ }
+
+ return $validConnections;
+ })
+ ->end()
+ ->end();
+
+ return $treeBuilder;
+ }
+}
diff --git a/src/DependencyInjection/Parameters.php b/src/DependencyInjection/Parameters.php
new file mode 100644
index 0000000..1d17925
--- /dev/null
+++ b/src/DependencyInjection/Parameters.php
@@ -0,0 +1,21 @@
+,
+ * connections: array{
+ * dbal: array,
+ * redis_cluster: array
+ * }
+ * },
+ * redis_cluster_connections?: array,
+ * ping_interval?: int|false,
+ * check_active_transactions?: bool
+ * } $mergedConfig
+ * @throws Exception
+ */
+ protected function loadInternal(array $mergedConfig, ContainerBuilder $container): void
+ {
+ $loader = new PhpFileLoader($container, new FileLocator(__DIR__ . '/../../config'));
+ $loader->load('services.php');
+ $this->registerNotResettableEntityManagers(
+ $container,
+ $mergedConfig['exclude_from_processing']['entity_managers'],
+ );
+ $this->registerNotPingableDbalConnections(
+ $container,
+ $mergedConfig['exclude_from_processing']['connections']['dbal'],
+ );
+ $this->registerNotPingableRedisClusterConnections(
+ $container,
+ $mergedConfig['exclude_from_processing']['connections']['redis_cluster'],
+ );
+ $this->tryToOptimizeAliveKeeper($container, $mergedConfig);
+ $this->tryToActivateTransactionChecks($container, $mergedConfig);
+ $this->registerReaderWriterConnections($container, $mergedConfig);
+ $this->registerRedisClusterConnections($container, $mergedConfig);
+ }
+
+ /**
+ * @param array $config
+ */
+ private function registerNotResettableEntityManagers(ContainerBuilder $container, array $config): void
+ {
+ $container->setParameter(Parameters::EXCLUDED_FROM_PROCESSING_ENTITY_MANAGERS, array_unique($config));
+ }
+
+ /**
+ * @param array $config
+ */
+ private function registerNotPingableDbalConnections(ContainerBuilder $container, array $config): void
+ {
+ $container->setParameter(Parameters::EXCLUDED_FROM_PROCESSING_DBAL_CONNECTIONS, array_unique($config));
+ }
+
+ /**
+ * @param array $config
+ */
+ private function registerNotPingableRedisClusterConnections(ContainerBuilder $container, array $config): void
+ {
+ $container->setParameter(Parameters::EXCLUDED_FROM_PROCESSING_REDIS_CLUSTER_CONNECTIONS, array_unique($config));
+ }
+
+ /**
+ * @param array{ping_interval?: false|int} $config
+ */
+ private function tryToOptimizeAliveKeeper(ContainerBuilder $container, array $config): void
+ {
+ if (!isset($config['ping_interval']) || $config['ping_interval'] === false) {
+ return;
+ }
+
+ $pingInterval = intval($config['ping_interval']);
+ $container->setParameter(Parameters::PING_INTERVAL, $pingInterval);
+ }
+
+ /**
+ * @param array{check_active_transactions?: bool} $config
+ */
+ private function tryToActivateTransactionChecks(ContainerBuilder $container, array $config): void
+ {
+ if (!isset($config['check_active_transactions']) || $config['check_active_transactions'] === false) {
+ return;
+ }
+
+ $checkActiveTransactions = $config['check_active_transactions'];
+ $container->setParameter(Parameters::CHECK_ACTIVE_TRANSACTIONS, $checkActiveTransactions);
+ }
+
+ /**
+ * @param array{failover_connections?: array} $config
+ */
+ private function registerReaderWriterConnections(ContainerBuilder $container, array $config): void
+ {
+ if (!isset($config['failover_connections'])) {
+ return;
+ }
+
+ $container->setParameter(AliveKeeperPass::FAILOVER_CONNECTIONS_PARAM_NAME, $config['failover_connections']);
+ }
+
+ /**
+ * @param array{redis_cluster_connections?: array} $config
+ */
+ private function registerRedisClusterConnections(ContainerBuilder $container, array $config): void
+ {
+ if (!isset($config['redis_cluster_connections'])) {
+ return;
+ }
+
+ $container->setParameter(
+ AliveKeeperPass::REDIS_CLUSTER_CONNECTIONS_PARAM_NAME,
+ $config['redis_cluster_connections'],
+ );
+ }
+}
diff --git a/src/ORM/ResettableEntityManager.php b/src/ORM/ResettableEntityManager.php
new file mode 100644
index 0000000..94e9f3d
--- /dev/null
+++ b/src/ORM/ResettableEntityManager.php
@@ -0,0 +1,198 @@
+repositoryFactory = $configuration->getRepositoryFactory();
+
+ parent::__construct($wrapped);
+ }
+
+ /**
+ * @template T as object
+ * @param class-string $className
+ * @return ObjectRepository
+ * @throws Exception
+ * @psalm-suppress LessSpecificImplementedReturnType
+ * @psalm-suppress MoreSpecificImplementedParamType
+ * @psalm-suppress MixedReturnTypeCoercion
+ */
+ public function getRepository($className): ObjectRepository
+ {
+ /** @psalm-suppress MixedReturnTypeCoercion */
+ return $this->repositoryFactory->getRepository($this, $className);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function createQuery($dql = ''): Query
+ {
+ $query = new Query($this);
+
+ if (! empty($dql)) {
+ $query->setDQL($dql);
+ }
+
+ return $query;
+ }
+
+ /**
+ * {@inheritDoc}
+ *
+ * @phpstan-ignore-next-line
+ */
+ public function createNativeQuery($sql, ResultSetMapping $rsm): NativeQuery
+ {
+ $query = new NativeQuery($this);
+
+ $query->setSQL($sql);
+ $query->setResultSetMapping($rsm);
+
+ return $query;
+ }
+
+ public function createQueryBuilder(): QueryBuilder
+ {
+ return new QueryBuilder($this);
+ }
+
+ public function clearOrResetIfNeeded(): void
+ {
+ if ($this->wrapped->isOpen()) {
+ $this->clear();
+
+ return;
+ }
+
+ $newEntityManager = $this->doctrineRegistry->resetManager($this->decoratedName);
+
+ if (!$newEntityManager instanceof EntityManagerInterface) {
+ throw new UnexpectedValueException(
+ sprintf('Invalid entity manager class - %s', $newEntityManager::class)
+ );
+ }
+ }
+ }
+
+ return;
+}
+
+/**
+ * @final
+ */
+// phpcs:ignore SlevomatCodingStandard.Classes.RequireAbstractOrFinal.ClassNeitherAbstractNorFinal
+class ResettableEntityManager extends EntityManagerDecorator
+{
+ private readonly RepositoryFactory $repositoryFactory;
+
+ public function __construct(
+ Configuration $configuration,
+ EntityManagerInterface $wrapped,
+ private readonly ManagerRegistry $doctrineRegistry,
+ private readonly string $decoratedName,
+ ) {
+ $this->repositoryFactory = $configuration->getRepositoryFactory();
+
+ parent::__construct($wrapped);
+ }
+
+ /**
+ * @template T as object
+ * @param class-string $className
+ * @return EntityRepository
+ * @throws Exception
+ * @psalm-suppress LessSpecificImplementedReturnType
+ * @psalm-suppress MoreSpecificImplementedParamType
+ * @psalm-suppress MixedReturnTypeCoercion
+ */
+ public function getRepository($className): EntityRepository
+ {
+ /** @psalm-suppress MixedReturnTypeCoercion */
+ return $this->repositoryFactory->getRepository($this, $className);
+ }
+
+ /**
+ * {@inheritDoc}
+ */
+ public function createQuery($dql = ''): Query
+ {
+ $query = new Query($this);
+
+ if (! empty($dql)) {
+ $query->setDQL($dql);
+ }
+
+ return $query;
+ }
+
+ public function createNativeQuery(string $sql, ResultSetMapping $rsm): NativeQuery
+ {
+ $query = new NativeQuery($this);
+
+ $query->setSQL($sql);
+ $query->setResultSetMapping($rsm);
+
+ return $query;
+ }
+
+ public function createQueryBuilder(): QueryBuilder
+ {
+ return new QueryBuilder($this);
+ }
+
+ public function clearOrResetIfNeeded(): void
+ {
+ if ($this->wrapped->isOpen()) {
+ $this->clear();
+
+ return;
+ }
+
+ $newEntityManager = $this->doctrineRegistry->resetManager($this->decoratedName);
+
+ if (!$newEntityManager instanceof EntityManagerInterface) {
+ throw new UnexpectedValueException(
+ sprintf('Invalid entity manager class - %s', $newEntityManager::class),
+ );
+ }
+ }
+}
diff --git a/src/Redis/Cluster/Connection/OptimizedRedisClusterAliveKeeper.php b/src/Redis/Cluster/Connection/OptimizedRedisClusterAliveKeeper.php
new file mode 100644
index 0000000..5d95112
--- /dev/null
+++ b/src/Redis/Cluster/Connection/OptimizedRedisClusterAliveKeeper.php
@@ -0,0 +1,49 @@
+lastPingAt = 0;
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function keepAlive(RedisCluster $redis, string $connectionName): void
+ {
+ if (!$this->isPingNeeded()) {
+ return;
+ }
+
+ $this->decorated->keepAlive($redis, $connectionName);
+ }
+
+ /**
+ * @throws Exception
+ */
+ private function isPingNeeded(): bool
+ {
+ $lastPingAt = $this->lastPingAt;
+ $now = time();
+ $this->lastPingAt = $now;
+
+ return $now - $lastPingAt >= $this->pingIntervalInSeconds;
+ }
+}
diff --git a/src/Redis/Cluster/Connection/PassiveIgnoringRedisClusterAliveKeeper.php b/src/Redis/Cluster/Connection/PassiveIgnoringRedisClusterAliveKeeper.php
new file mode 100644
index 0000000..f3bf6fa
--- /dev/null
+++ b/src/Redis/Cluster/Connection/PassiveIgnoringRedisClusterAliveKeeper.php
@@ -0,0 +1,24 @@
+isProxyInitialized()) {
+ return;
+ }
+
+ $this->decorated->keepAlive($redis, $connectionName);
+ }
+}
diff --git a/src/Redis/Cluster/Connection/PingingRedisClusterAliveKeeper.php b/src/Redis/Cluster/Connection/PingingRedisClusterAliveKeeper.php
new file mode 100644
index 0000000..1ca3e7a
--- /dev/null
+++ b/src/Redis/Cluster/Connection/PingingRedisClusterAliveKeeper.php
@@ -0,0 +1,42 @@
+,
+ * 2: float,
+ * 3: float,
+ * 4: bool,
+ * 5: string|null
+ * } $constructorArguments
+ */
+ public function __construct(
+ private readonly array $constructorArguments,
+ private readonly LoggerInterface $logger,
+ ) {}
+
+ public function keepAlive(RedisCluster $redis, string $connectionName): void
+ {
+ try {
+ $redis->ping('hello');
+ } catch (RedisClusterException $e) {
+ $this->logger->info(
+ sprintf("Exceptional reconnect for redis cluster connection '%s'", $connectionName),
+ ['exception' => $e],
+ );
+ // redis cluster does not have a reconnect method and does not work with shard master to slave failover,
+ // so this hack has to be used
+ call_user_func_array([$redis, '__construct'], $this->constructorArguments);
+ }
+ }
+}
diff --git a/src/Redis/Cluster/Connection/RedisClusterAliveKeeper.php b/src/Redis/Cluster/Connection/RedisClusterAliveKeeper.php
new file mode 100644
index 0000000..feeda69
--- /dev/null
+++ b/src/Redis/Cluster/Connection/RedisClusterAliveKeeper.php
@@ -0,0 +1,12 @@
+ $connections
+ * @param array $aliveKeepers
+ */
+ public function __construct(
+ private array $connections,
+ private readonly array $aliveKeepers,
+ ) {}
+
+ public function keepAlive(): void
+ {
+ foreach ($this->aliveKeepers as $connectionName => $aliveKeeper) {
+ if (!isset($this->connections[$connectionName])) {
+ throw new RuntimeException(
+ sprintf('Connection "%s" is missing.', $connectionName),
+ );
+ }
+
+ $connection = $this->connections[$connectionName];
+ $aliveKeeper->keepAlive($connection, $connectionName);
+ }
+ }
+}
diff --git a/src/RequestCycle/Initializer.php b/src/RequestCycle/Initializer.php
new file mode 100644
index 0000000..d8a899a
--- /dev/null
+++ b/src/RequestCycle/Initializer.php
@@ -0,0 +1,10 @@
+ $initializers
+ */
+ public function __construct(
+ private readonly iterable $initializers,
+ ) {}
+
+ public function initialize(): void
+ {
+ foreach ($this->initializers as $initializer) {
+ $initializer->initialize();
+ }
+ }
+}
diff --git a/src/SwooleBundleResetterBundle.php b/src/SwooleBundleResetterBundle.php
new file mode 100644
index 0000000..87d86cf
--- /dev/null
+++ b/src/SwooleBundleResetterBundle.php
@@ -0,0 +1,19 @@
+addCompilerPass(new EntityManagerDecoratorPass());
+ $container->addCompilerPass(new AliveKeeperPass());
+ }
+}
diff --git a/symfony.lock b/symfony.lock
new file mode 100644
index 0000000..9fb6904
--- /dev/null
+++ b/symfony.lock
@@ -0,0 +1,143 @@
+{
+ "doctrine/annotations": {
+ "version": "2.0",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "a2759dd6123694c8d901d0ec80006e044c2e6457"
+ },
+ "files": [
+ "config/routes/annotations.yaml"
+ ]
+ },
+ "doctrine/doctrine-bundle": {
+ "version": "2.10",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "2.4",
+ "ref": "013b823e7fee65890b23e40f31e6667a1ac519ac"
+ },
+ "files": [
+ "config/packages/doctrine.yaml",
+ "src/Entity/.gitignore",
+ "src/Repository/.gitignore"
+ ]
+ },
+ "friendsofphp/php-cs-fixer": {
+ "version": "3.22",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "3.0",
+ "ref": "be2103eb4a20942e28a6dd87736669b757132435"
+ },
+ "files": [
+ ".php-cs-fixer.dist.php"
+ ]
+ },
+ "phpstan/phpstan": {
+ "version": "1.10",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "d74d4d719d5f53856c9c13544aa22d44144b1819"
+ }
+ },
+ "phpunit/phpunit": {
+ "version": "10.2",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "9.6",
+ "ref": "7364a21d87e658eb363c5020c072ecfdc12e2326"
+ },
+ "files": [
+ ".env.test",
+ "phpunit.xml.dist",
+ "tests/bootstrap.php"
+ ]
+ },
+ "squizlabs/php_codesniffer": {
+ "version": "3.7",
+ "recipe": {
+ "repo": "github.com/symfony/recipes-contrib",
+ "branch": "main",
+ "version": "3.6",
+ "ref": "1019e5c08d4821cb9b77f4891f8e9c31ff20ac6f"
+ }
+ },
+ "symfony/console": {
+ "version": "5.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "da0c8be8157600ad34f10ff0c9cc91232522e047"
+ },
+ "files": [
+ "bin/console"
+ ]
+ },
+ "symfony/flex": {
+ "version": "2.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "1.0",
+ "ref": "146251ae39e06a95be0fe3d13c807bcf3938b172"
+ },
+ "files": [
+ ".env"
+ ]
+ },
+ "symfony/framework-bundle": {
+ "version": "5.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.4",
+ "ref": "3cd216a4d007b78d8554d44a5b1c0a446dab24fb"
+ },
+ "files": [
+ "config/packages/cache.yaml",
+ "config/packages/framework.yaml",
+ "config/preload.php",
+ "config/routes/framework.yaml",
+ "config/services.yaml",
+ "public/index.php",
+ "src/Controller/.gitignore",
+ "src/Kernel.php"
+ ]
+ },
+ "symfony/phpunit-bridge": {
+ "version": "6.3",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "6.3",
+ "ref": "01dfaa98c58f7a7b5a9b30e6edb7074af7ed9819"
+ },
+ "files": [
+ ".env.test",
+ "bin/phpunit",
+ "phpunit.xml.dist",
+ "tests/bootstrap.php"
+ ]
+ },
+ "symfony/routing": {
+ "version": "5.4",
+ "recipe": {
+ "repo": "github.com/symfony/recipes",
+ "branch": "main",
+ "version": "5.3",
+ "ref": "85de1d8ae45b284c3c84b668171d2615049e698f"
+ },
+ "files": [
+ "config/packages/routing.yaml",
+ "config/routes.yaml"
+ ]
+ }
+}
diff --git a/tests/Functional/FailoverAwareTest.php b/tests/Functional/FailoverAwareTest.php
new file mode 100644
index 0000000..3596d66
--- /dev/null
+++ b/tests/Functional/FailoverAwareTest.php
@@ -0,0 +1,62 @@
+get('doctrine.orm.default_entity_manager');
+ $connection = $em->getConnection();
+ self::assertInstanceOf(ConnectionMock::class, $connection);
+
+ self::assertFalse($connection->isConnected());
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertFalse($connection->isConnected());
+ }
+
+ public function testFailoverAliveKeeperOnRequestStart(): void
+ {
+ $client = self::createClient();
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ $connection = $em->getConnection();
+ self::assertInstanceOf(ConnectionMock::class, $connection);
+
+ self::assertFalse($connection->isConnected());
+ $connection->getNativeConnection(); // calls connect() internally
+ $connection->beginTransaction();
+ self::assertTrue($connection->isTransactionActive());
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertTrue($connection->isConnected());
+ self::assertSame('SELECT @@global.innodb_read_only;', $connection->getQuery());
+ self::assertFalse($connection->isTransactionActive());
+ }
+
+ protected static function getTestCase(): string
+ {
+ return 'FailoverAwareTest';
+ }
+}
diff --git a/tests/Functional/HttpRequestLifecycleTest.php b/tests/Functional/HttpRequestLifecycleTest.php
new file mode 100644
index 0000000..afb718d
--- /dev/null
+++ b/tests/Functional/HttpRequestLifecycleTest.php
@@ -0,0 +1,255 @@
+setUpInternal();
+ $client = self::createClient();
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ $connection = $em->getConnection();
+ $redisCluster = self::getContainer()->get(RedisCluster::class);
+ $redisCluster->setIsProxyInitialized(false);
+
+ self::assertFalse($connection->isConnected());
+ self::assertFalse($redisCluster->wasConstructorCalled());
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertFalse($connection->isConnected());
+ // redis cluster does not provide conenction instance without creating the connection
+ self::assertFalse($redisCluster->wasConstructorCalled());
+ }
+
+ public function testPingConnectionsOnRequestStart(): void
+ {
+ $this->setUpInternal('configs/config-conn-mock.yaml');
+ $client = self::createClient([], 'configs/config-conn-mock.yaml');
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ /** @var ConnectionMock $connection */
+ $connection = $em->getConnection();
+ /** @var EntityManagerInterface $emExcluded */
+ $emExcluded = self::getContainer()->get('doctrine.orm.excluded_entity_manager');
+ /** @var ConnectionMock $connectionExcluded */
+ $connectionExcluded = $emExcluded->getConnection();
+ $redisCluster = self::getContainer()->get(RedisCluster::class);
+ $redisClusterExcluded = self::getContainer()->get(RedisCluster::class . '2');
+
+ self::assertFalse($connection->isConnected());
+ self::assertFalse($connectionExcluded->isConnected());
+ self::assertFalse($redisCluster->wasConstructorCalled());
+ self::assertFalse($redisClusterExcluded->wasConstructorCalled());
+ $connection->getNativeConnection(); // simulates real connection usage, calls connect() internally
+ $connectionExcluded->getNativeConnection(); // simulates real connection usage, calls connect() internally
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertTrue($connection->isConnected());
+ self::assertSame('SELECT 1', $connection->getQuery());
+ self::assertNull($connectionExcluded->getQuery());
+ self::assertTrue($connectionExcluded->isConnected());
+ self::assertTrue($redisCluster->wasConstructorCalled());
+ self::assertSame(
+ $redisCluster->getConstructorParametersFirst(),
+ $redisCluster->getConstructorParametersSecond()
+ );
+ self::assertFalse($redisClusterExcluded->wasConstructorCalled());
+ }
+
+ public function testCheckIfConnectionsHaveActiveTransactionsOnRequestStart(): void
+ {
+ $this->setUpInternal('configs/config-trans-check.yaml');
+ $client = self::createClient([], 'configs/config-trans-check.yaml');
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ $connection = $em->getConnection();
+ /** @var EntityManagerInterface $emExcluded */
+ $emExcluded = self::getContainer()->get('doctrine.orm.excluded_entity_manager');
+ $connectionExcluded = $emExcluded->getConnection();
+
+ self::assertFalse($connection->isConnected());
+ self::assertFalse($connectionExcluded->isConnected());
+ $connection->getNativeConnection(); // simulates real connection usage, calls connect() internally
+ $connection->beginTransaction();
+ $connectionExcluded->getNativeConnection(); // simulates real connection usage, calls connect() internally
+ $connectionExcluded->beginTransaction();
+ self::assertTrue($connection->isTransactionActive());
+ self::assertTrue($connectionExcluded->isTransactionActive());
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertTrue($connection->isConnected());
+ self::assertFalse($connection->isTransactionActive());
+ self::assertTrue($connectionExcluded->isConnected());
+ self::assertTrue($connectionExcluded->isTransactionActive());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testEmWillBeResetWithServicesResetter(): void
+ {
+ $this->setUpInternal();
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ self::assertInstanceOf(ResettableEntityManager::class, $em);
+
+ $client = self::createClient();
+ $checker = $client->getContainer()->get(EntityManagerChecker::class . '.default');
+ $client->disableReboot();
+ $client->request('GET', '/');
+
+ self::assertSame(1, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ $client->request('GET', '/');
+
+ self::assertSame(2, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testEmWillBeResetOnErrorWithServicesResetter(): void
+ {
+ $this->setUpInternal();
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ self::assertInstanceOf(ResettableEntityManager::class, $em);
+ $refl = new ReflectionClass(ResettableEntityManager::class);
+ $wrappedProperty = $refl->getProperty('wrapped');
+ $wrappedProperty->setAccessible(true);
+ $wrapped = $wrappedProperty->getValue($em);
+
+ $client = self::createClient();
+ $checker = $client->getContainer()->get(EntityManagerChecker::class . '.default');
+ $client->disableReboot();
+ $client->request('GET', '/persist-error');
+
+ self::assertSame(1, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ $client->request('GET', '/persist-error');
+
+ self::assertSame(2, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ $client->request('GET', '/persist-error');
+
+ self::assertSame(3, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ /** @var EntityManagerInterface $wrapped2 */
+ $wrapped2 = $wrappedProperty->getValue($em);
+ self::assertSame($wrapped, $wrapped2);
+ self::assertTrue($wrapped2->isOpen());
+
+ $response = $client->request('GET', '/remove-all');
+
+ self::assertSame(4, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+ self::assertSame(0, $response->count()); // this means that there was an empty response
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testExcludedEmWillBeResetOnErrorWithServicesResetterButRepositoryWontBeResetted(): void
+ {
+ if (version_compare(Kernel::VERSION, '6.2.0') >= 0) {
+ $this->markTestSkipped('This test is not needed with Symfony 6.2');
+
+ return;
+ }
+
+ $this->setUpInternal();
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.excluded_entity_manager');
+ self::assertNotInstanceOf(ResettableEntityManager::class, $em);
+
+ $client = self::createClient();
+ $checker = $client->getContainer()->get(EntityManagerChecker::class . '.excluded');
+ $client->disableReboot();
+ $client->request('GET', '/persist-error-excluded');
+
+ self::assertSame(1, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ $client->request('GET', '/persist-error-excluded');
+
+ self::assertSame(2, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ $response = $client->request('GET', '/remove-all-excluded');
+
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+ self::assertNotSame(0, $response->count());
+ self::assertStringContainsString(
+ 'Detached entity SwooleBundle\\ResetterBundle\\Tests\\Functional\\app\\HttpRequestLifecycleTest\\ExcludedEntity\\ExcludedTestEntity2', // phpcs:ignore
+ $response->html()
+ );
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function testExcludedEmWontBeWrappedAndWillBeResetWithDefaultDoctrineServicesResetter(): void
+ {
+ $this->setUpInternal();
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.excluded_entity_manager');
+ self::assertInstanceOf(EntityManager::class, $em);
+
+ $client = self::createClient();
+ $checker = $client->getContainer()->get(EntityManagerChecker::class . '.excluded');
+ $client->disableReboot();
+ $client->request('GET', '/persist-excluded');
+
+ self::assertSame(1, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+
+ $client->request('GET', '/persist-excluded');
+
+ self::assertSame(2, $checker->getNumberOfChecks());
+ self::assertTrue($checker->wasEmptyOnLastCheck());
+ }
+
+ protected static function getTestCase(): string
+ {
+ return 'HttpRequestLifecycleTest';
+ }
+
+ private function setUpInternal(string $rootConfig = 'configs/config.yaml'): void
+ {
+ self::bootTestKernel($rootConfig);
+ self::runCommand('cache:clear --no-warmup');
+ self::runCommand('cache:warmup');
+ self::runCommand('doctrine:database:drop --force --connection default');
+ self::runCommand('doctrine:schema:create --em default');
+ self::runCommand('doctrine:database:drop --force --connection excluded');
+ self::runCommand('doctrine:schema:create --em excluded');
+ }
+}
diff --git a/tests/Functional/OptimisedAliveKeeperTest.php b/tests/Functional/OptimisedAliveKeeperTest.php
new file mode 100644
index 0000000..3694537
--- /dev/null
+++ b/tests/Functional/OptimisedAliveKeeperTest.php
@@ -0,0 +1,77 @@
+get($doctrineHandlerSvcId);
+ $refl = new ReflectionClass(OptimizedDBALAliveKeeper::class);
+ $intervalParam = $refl->getProperty('pingIntervalInSeconds');
+ $intervalParam->setAccessible(true);
+
+ self::assertSame(10, $intervalParam->getValue($handler));
+
+ $redisHandlerSvcId = sprintf('%s_%s', OptimizedRedisClusterAliveKeeper::class, 'default');
+ /** @var OptimizedRedisClusterAliveKeeper $handler */
+ $handler = self::getContainer()->get($redisHandlerSvcId);
+ $refl2 = new ReflectionClass(OptimizedRedisClusterAliveKeeper::class);
+ $intervalParam2 = $refl2->getProperty('pingIntervalInSeconds');
+ $intervalParam2->setAccessible(true);
+
+ self::assertSame(10, $intervalParam2->getValue($handler));
+ }
+
+ public function testThatOnlyFirstPingWillBeMadeIn10SecondsOnRequestStart(): void
+ {
+ $client = self::createClient();
+
+ /** @var EntityManagerInterface $em */
+ $em = self::getContainer()->get('doctrine.orm.default_entity_manager');
+ /** @var ConnectionMock $connection */
+ $connection = $em->getConnection();
+ /** @var RedisClusterSpy $redisCluster */
+ $redisCluster = self::getContainer()->get(RedisCluster::class);
+
+ self::assertFalse($connection->isConnected());
+ self::assertSame(1, $redisCluster->getConstructorCalls());
+ $connection->getNativeConnection(); // simulates real connection usage, calls connect() internally
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertTrue($connection->isConnected());
+ self::assertSame(1, $connection->getQueriesCount());
+ self::assertSame(1, $redisCluster->getConstructorCalls());
+ self::assertSame(1, $redisCluster->getPingCount());
+
+ $client->request('GET', '/dummy'); // this action does nothing with the database
+ self::assertSame(1, $connection->getQueriesCount());
+ self::assertSame(1, $redisCluster->getConstructorCalls());
+ self::assertSame(1, $redisCluster->getPingCount());
+ }
+
+ protected static function getTestCase(): string
+ {
+ return 'OptimisedAliveKeeperTest';
+ }
+}
diff --git a/tests/Functional/TestCase.php b/tests/Functional/TestCase.php
new file mode 100644
index 0000000..7979014
--- /dev/null
+++ b/tests/Functional/TestCase.php
@@ -0,0 +1,142 @@
+remove($dir);
+ }
+
+ protected static function getKernelClass(): string
+ {
+ require_once __DIR__ . '/app/AppKernel.php';
+
+ return AppKernel::class;
+ }
+
+ /**
+ * @inheritdoc
+ * @throws InvalidArgumentException
+ */
+ protected static function createKernel(array $options = []): KernelInterface
+ {
+ $class = self::getKernelClass();
+
+ if (!isset($options['test_case'])) {
+ throw new InvalidArgumentException('The option "test_case" must be set.');
+ }
+
+ return new $class(
+ static::getVarDir(),
+ $options['test_case'],
+ $options['root_config'] ?? 'config.yml',
+ $options['environment'] ?? strtolower(static::getVarDir() . $options['test_case']),
+ $options['debug'] ?? true
+ );
+ }
+
+ /**
+ * Creates a Client.
+ *
+ * @param array $server An array of server parameters
+ */
+ protected static function createClient(
+ array $server = [],
+ string $rootConfig = 'configs/config.yaml',
+ ): KernelBrowser {
+ static::bootTestKernel($rootConfig);
+
+ $client = self::getContainer()->get('test.client');
+ $client->setServerParameters($server);
+
+ return $client;
+ }
+
+ /**
+ * @throws Exception
+ */
+ protected static function runCommand(string $command): void
+ {
+ $command = sprintf('%s --quiet', $command);
+ self::getApplication()->run(new StringInput($command));
+ }
+
+ protected static function bootTestKernel(string $rootConfig = 'configs/config.yaml'): void
+ {
+ self::bootKernel(['test_case' => static::getTestCase(), 'root_config' => $rootConfig]);
+ }
+
+ abstract protected static function getTestCase(): string;
+
+ protected static function getApplication(): Application
+ {
+ if (self::$application === null) {
+ self::$application = new Application(self::$kernel);
+ self::$application->setAutoExit(false);
+ }
+
+ return self::$application;
+ }
+
+ protected static function getVarDir(): string
+ {
+ return 'PFCBB' . substr(strrchr(static::class, '\\'), 1);
+ }
+}
diff --git a/tests/Functional/app/AppKernel.php b/tests/Functional/app/AppKernel.php
new file mode 100644
index 0000000..be30040
--- /dev/null
+++ b/tests/Functional/app/AppKernel.php
@@ -0,0 +1,118 @@
+isAbsolutePath($rootConfig) && !is_file($rootConfig)) {
+ throw new InvalidArgumentException(sprintf('The root config "%s" does not exist.', $rootConfig));
+ }
+
+ $this->rootConfig = $rootConfig;
+
+ parent::__construct($environment, $debug);
+ }
+
+ public function getProjectDir(): string
+ {
+ return __DIR__;
+ }
+
+ /**
+ * @return iterable
+ * @throws RuntimeException
+ */
+ public function registerBundles(): iterable
+ {
+ $filename = $this->getRootDir() . '/config/bundles.php';
+
+ if (!is_file($filename)) {
+ throw new RuntimeException(sprintf('The bundles file "%s" does not exist.', $filename));
+ }
+
+ return include $filename;
+ }
+
+ public function getRootDir(): string
+ {
+ return __DIR__;
+ }
+
+ public function getCacheDir(): string
+ {
+ return sys_get_temp_dir() . '/' . $this->varDir . '/' . $this->testCase . '/cache/' . $this->environment;
+ }
+
+ public function getLogDir(): string
+ {
+ return sys_get_temp_dir() . '/' . $this->varDir . '/' . $this->testCase . '/logs';
+ }
+
+ /**
+ * @throws Exception
+ */
+ public function registerContainerConfiguration(LoaderInterface $loader): void
+ {
+ $loader->load($this->rootConfig);
+ }
+
+ public function serialize(): string
+ {
+ return serialize([
+ $this->varDir,
+ $this->testCase,
+ $this->rootConfig,
+ $this->getEnvironment(),
+ $this->isDebug(),
+ ]);
+ }
+
+ /**
+ * @throws InvalidArgumentException
+ */
+ public function unserialize(string $str): void
+ {
+ $data = unserialize($str);
+ $this->__construct($data[0], $data[1], $data[2], $data[3], $data[4]);
+ }
+
+ /**
+ * @return array
+ */
+ protected function getKernelParameters(): array
+ {
+ $parameters = parent::getKernelParameters();
+ $parameters['kernel.test_case'] = $this->testCase;
+
+ return $parameters;
+ }
+}
diff --git a/tests/Functional/app/FailoverAwareTest/ConnectionMock.php b/tests/Functional/app/FailoverAwareTest/ConnectionMock.php
new file mode 100644
index 0000000..c9dfb03
--- /dev/null
+++ b/tests/Functional/app/FailoverAwareTest/ConnectionMock.php
@@ -0,0 +1,71 @@
+|list $params
+ * @phpstan-param WrapperParameterTypeArray $types
+ */
+ public function executeQuery(
+ string $sql,
+ array $params = [],
+ array $types = [],
+ ?QueryCacheProfile $qcp = null,
+ ): Result {
+ $args = func_get_args();
+ $this->query = $args[0];
+
+ // phpcs:disable
+ if (version_compare(InstalledVersions::getVersion('doctrine/dbal'), '4.0.0', '<')) {
+ return new class extends Result {
+ public function __construct() {}
+
+ /**
+ * @return mixed
+ */
+ public function fetchOne()
+ {
+ return '1';
+ }
+
+ /**
+ * @return mixed
+ */
+ public function fetch($fetchMode = null, $cursorOrientation = PDO::FETCH_ORI_NEXT, $cursorOffset = 0)
+ {
+ return 1;
+ }
+ };
+ }
+
+ // phpcs:enable
+ return new class extends Result {
+ public function __construct() {}
+
+ public function fetchOne(): mixed
+ {
+ return '1';
+ }
+ };
+ }
+
+ public function getQuery(): string
+ {
+ return $this->query;
+ }
+}
diff --git a/tests/Functional/app/FailoverAwareTest/TestController.php b/tests/Functional/app/FailoverAwareTest/TestController.php
new file mode 100644
index 0000000..8da616d
--- /dev/null
+++ b/tests/Functional/app/FailoverAwareTest/TestController.php
@@ -0,0 +1,15 @@
+|list $params
+ * @phpstan-param WrapperParameterTypeArray $types
+ */
+ public function executeQuery(
+ string $sql,
+ array $params = [],
+ array $types = [],
+ ?QueryCacheProfile $qcp = null,
+ ): Result {
+ $args = func_get_args();
+ $this->query = $args[0];
+
+ // phpcs:disable
+ if (version_compare(InstalledVersions::getVersion('doctrine/dbal'), '4.0.0', '<')) {
+ return new class extends Result {
+ public function __construct() {}
+
+ public function fetchOne(): mixed
+ {
+ return '1';
+ }
+
+ public function fetch(
+ $fetchMode = null,
+ $cursorOrientation = PDO::FETCH_ORI_NEXT,
+ $cursorOffset = 0,
+ ): mixed {
+ return 1;
+ }
+ };
+ }
+
+ // phpcs:disable
+ return new class extends Result {
+ public function __construct() {}
+
+ public function fetchOne(): mixed
+ {
+ return '1';
+ }
+ };
+ }
+
+ public function getQuery(): ?string
+ {
+ return $this->query;
+ }
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/Entity/TestEntity.php b/tests/Functional/app/HttpRequestLifecycleTest/Entity/TestEntity.php
new file mode 100644
index 0000000..5ebf7d1
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/Entity/TestEntity.php
@@ -0,0 +1,20 @@
+numberOfChecks++;
+ $uow = $this->entityManager->getUnitOfWork();
+ $this->wasEmptyOnLastCheck = empty($uow->getIdentityMap());
+ }
+
+ public function getNumberOfChecks(): int
+ {
+ return $this->numberOfChecks;
+ }
+
+ public function wasEmptyOnLastCheck(): bool
+ {
+ return $this->wasEmptyOnLastCheck;
+ }
+
+ /**
+ * @return array
+ */
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ KernelEvents::REQUEST => 'checkEntityManager',
+ ];
+ }
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/ExcludedTestEntity.php b/tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/ExcludedTestEntity.php
new file mode 100644
index 0000000..51586cd
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/ExcludedTestEntity.php
@@ -0,0 +1,20 @@
+ true])]
+ #[ORM\GeneratedValue]
+ #[ORM\Id]
+ private int $id;
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/ExcludedTestEntity2.php b/tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/ExcludedTestEntity2.php
new file mode 100644
index 0000000..239527b
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/ExcludedEntity/ExcludedTestEntity2.php
@@ -0,0 +1,21 @@
+ true])]
+ #[ORM\Id]
+ private int $id,
+ ) {}
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/ExcludedTestController.php b/tests/Functional/app/HttpRequestLifecycleTest/ExcludedTestController.php
new file mode 100644
index 0000000..f3cd0ef
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/ExcludedTestController.php
@@ -0,0 +1,60 @@
+repository = $entityManager->getRepository(ExcludedTestEntity2::class);
+ }
+
+ public function doNothingAction(): Response
+ {
+ return new Response();
+ }
+
+ public function persistTestAction(): Response
+ {
+ $this->entityManager->persist(new ExcludedTestEntity());
+ $this->entityManager->flush();
+
+ return new Response();
+ }
+
+ public function persistErrorTestAction(): Response
+ {
+ try {
+ $this->entityManager->persist(new ExcludedTestEntity2(10));
+ $this->entityManager->flush();
+ } catch (UniqueConstraintViolationException) {
+ }
+
+ return new Response();
+ }
+
+ public function removeAllPersistedAction(): Response
+ {
+ $all = $this->repository->findAll();
+
+ foreach ($all as $testEntity2) {
+ $this->entityManager->remove($testEntity2);
+ }
+
+ $this->entityManager->flush();
+
+ return new Response();
+ }
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/RedisClusterSpy.php b/tests/Functional/app/HttpRequestLifecycleTest/RedisClusterSpy.php
new file mode 100644
index 0000000..cddef04
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/RedisClusterSpy.php
@@ -0,0 +1,96 @@
+ */
+ private array $constructorParametersFirst = [];
+
+ /** @var array */
+ private array $constructorParametersSecond = [];
+
+ private bool $initialized = true;
+
+ public function __construct(
+ ?string $name,
+ ?array $seeds,
+ int|float|null $timeout = null,
+ int|float|null $readTimeout = null,
+ bool $persistent = false,
+ mixed $auth = null,
+ ) {
+ $this->constructorCalls++;
+
+ if ($this->constructorCalls === 1) {
+ $this->constructorParametersFirst = [$name, $seeds, $timeout, $readTimeout];
+ } elseif ($this->constructorCalls > 1) {
+ $this->wasConstructorCalled = true;
+ $this->constructorParametersSecond = [$name, $seeds, $timeout, $readTimeout];
+ }
+ }
+
+ public function wasConstructorCalled(): bool
+ {
+ return $this->wasConstructorCalled;
+ }
+
+ /**
+ * @return array
+ */
+ public function getConstructorParametersFirst(): array
+ {
+ return $this->constructorParametersFirst;
+ }
+
+ /**
+ * @return array
+ */
+ public function getConstructorParametersSecond(): array
+ {
+ return $this->constructorParametersSecond;
+ }
+
+ public function ping(array|string $key_or_address, ?string $message = null): mixed
+ {
+ throw new RedisClusterException('Test exception');
+ }
+
+ public function setProxyInitializer(?Closure $initializer = null): void {}
+
+ public function getProxyInitializer(): ?Closure
+ {
+ return null;
+ }
+
+ public function initializeProxy(): bool
+ {
+ return true;
+ }
+
+ public function isProxyInitialized(): bool
+ {
+ return $this->initialized;
+ }
+
+ public function setIsProxyInitialized(bool $initialised = true): bool
+ {
+ return $this->initialized = $initialised;
+ }
+
+ public function getWrappedValueHolderValue(): ?object
+ {
+ return null;
+ }
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/TestController.php b/tests/Functional/app/HttpRequestLifecycleTest/TestController.php
new file mode 100644
index 0000000..582bb45
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/TestController.php
@@ -0,0 +1,60 @@
+repository = $entityManager->getRepository(TestEntity2::class);
+ }
+
+ public function doNothingAction(): Response
+ {
+ return new Response();
+ }
+
+ public function persistTestAction(): Response
+ {
+ $this->entityManager->persist(new TestEntity());
+ $this->entityManager->flush();
+
+ return new Response();
+ }
+
+ public function persistErrorTestAction(): Response
+ {
+ try {
+ $this->entityManager->persist(new TestEntity2(10));
+ $this->entityManager->flush();
+ } catch (UniqueConstraintViolationException) {
+ }
+
+ return new Response();
+ }
+
+ public function removeAllPersistedAction(): Response
+ {
+ $all = $this->repository->findAll();
+
+ foreach ($all as $testEntity2) {
+ $this->entityManager->remove($testEntity2);
+ }
+
+ $this->entityManager->flush();
+
+ return new Response();
+ }
+}
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/configs/config-conn-mock.yaml b/tests/Functional/app/HttpRequestLifecycleTest/configs/config-conn-mock.yaml
new file mode 100644
index 0000000..1363f8e
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/configs/config-conn-mock.yaml
@@ -0,0 +1,11 @@
+imports:
+ - {resource: config.yaml}
+
+doctrine:
+ dbal:
+ default_connection: default
+ connections:
+ default:
+ wrapper_class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ConnectionMock
+ excluded:
+ wrapper_class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ConnectionMock
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/configs/config-trans-check.yaml b/tests/Functional/app/HttpRequestLifecycleTest/configs/config-trans-check.yaml
new file mode 100644
index 0000000..a7fd954
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/configs/config-trans-check.yaml
@@ -0,0 +1,14 @@
+imports:
+ - {resource: config.yaml}
+
+doctrine:
+ dbal:
+ default_connection: default
+ connections:
+ default:
+ wrapper_class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ConnectionMock
+ excluded:
+ wrapper_class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ConnectionMock
+
+swoole_bundle_resetter:
+ check_active_transactions: true
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/configs/config.yaml b/tests/Functional/app/HttpRequestLifecycleTest/configs/config.yaml
new file mode 100644
index 0000000..3556135
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/configs/config.yaml
@@ -0,0 +1,62 @@
+imports:
+ - {resource: ../../config/framework.yaml}
+ - {resource: ../../config/doctrine.yaml}
+
+swoole_bundle_resetter:
+ exclude_from_processing:
+ entity_managers:
+ - excluded
+ connections:
+ dbal:
+ - excluded
+ redis_cluster:
+ - excluded
+ redis_cluster_connections:
+ default: 'RedisCluster'
+ excluded: 'RedisCluster2'
+
+services:
+ _defaults:
+ public: false
+ autowire: true
+ autoconfigure: true
+
+ SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\TestController:
+ public: true
+ arguments:
+ $entityManager: '@doctrine.orm.default_entity_manager'
+
+ SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\EntityManagerChecker.default:
+ public: true
+ class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\EntityManagerChecker
+ arguments:
+ $entityManager: '@doctrine.orm.default_entity_manager'
+
+ SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ExcludedTestController:
+ public: true
+ arguments:
+ $entityManager: '@doctrine.orm.excluded_entity_manager'
+
+ SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\EntityManagerChecker.excluded:
+ public: true
+ class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\EntityManagerChecker
+ arguments:
+ $entityManager: '@doctrine.orm.excluded_entity_manager'
+
+ RedisCluster:
+ class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\RedisClusterSpy
+ public: true
+ arguments:
+ $name: 'default'
+ $seeds: ['localhost:6379']
+ $timeout: 2
+ $readTimeout: 2
+
+ RedisCluster2:
+ class: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\RedisClusterSpy
+ public: true
+ arguments:
+ $name: 'default'
+ $seeds: [ 'localhost:6379' ]
+ $timeout: 2
+ $readTimeout: 2
diff --git a/tests/Functional/app/HttpRequestLifecycleTest/configs/routing.yaml b/tests/Functional/app/HttpRequestLifecycleTest/configs/routing.yaml
new file mode 100644
index 0000000..4dc09cb
--- /dev/null
+++ b/tests/Functional/app/HttpRequestLifecycleTest/configs/routing.yaml
@@ -0,0 +1,34 @@
+resetter_persist_test:
+ methods: ['GET']
+ path: /
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\TestController::persistTestAction
+
+resetter_persist_error_test:
+ methods: ['GET']
+ path: /persist-error
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\TestController::persistErrorTestAction
+
+resetter_remove_all_test:
+ methods: ['GET']
+ path: /remove-all
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\TestController::removeAllPersistedAction
+
+resetter_do_nothing:
+ methods: ['GET']
+ path: /dummy
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\TestController::doNothingAction
+
+resetter_excluded_persist_test:
+ methods: ['GET']
+ path: /persist-excluded
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ExcludedTestController::persistTestAction
+
+resetter_excluded_persist_error_test:
+ methods: ['GET']
+ path: /persist-error-excluded
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ExcludedTestController::persistErrorTestAction
+
+resetter_excluded_remove_all_test:
+ methods: ['GET']
+ path: /remove-all-excluded
+ controller: SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\ExcludedTestController::removeAllPersistedAction
diff --git a/tests/Functional/app/OptimisedAliveKeeperTest/ConnectionMock.php b/tests/Functional/app/OptimisedAliveKeeperTest/ConnectionMock.php
new file mode 100644
index 0000000..f162ecf
--- /dev/null
+++ b/tests/Functional/app/OptimisedAliveKeeperTest/ConnectionMock.php
@@ -0,0 +1,77 @@
+|list $params
+ * @phpstan-param WrapperParameterTypeArray $types
+ */
+ public function executeQuery(
+ string $sql,
+ array $params = [],
+ array $types = [],
+ ?QueryCacheProfile $qcp = null,
+ ): Result {
+ $args = func_get_args();
+ $this->queries[] = $args[0];
+
+ // phpcs:disable
+ if (version_compare(InstalledVersions::getVersion('doctrine/dbal'), '4.0.0', '<')) {
+ return new class extends Result {
+ public function __construct() {}
+
+ public function fetchOne(): mixed
+ {
+ return '1';
+ }
+
+ public function fetch(
+ $fetchMode = null,
+ $cursorOrientation = PDO::FETCH_ORI_NEXT,
+ $cursorOffset = 0,
+ ): mixed {
+ return 1;
+ }
+ };
+ }
+
+ // phpcs:enable
+ return new class extends Result {
+ public function __construct() {}
+
+ public function fetchOne(): mixed
+ {
+ return '1';
+ }
+ };
+ }
+
+ /**
+ * @return array
+ */
+ public function getQueries(): array
+ {
+ return $this->queries;
+ }
+
+ public function getQueriesCount(): int
+ {
+ return count($this->queries);
+ }
+}
diff --git a/tests/Functional/app/OptimisedAliveKeeperTest/RedisClusterSpy.php b/tests/Functional/app/OptimisedAliveKeeperTest/RedisClusterSpy.php
new file mode 100644
index 0000000..6a02a30
--- /dev/null
+++ b/tests/Functional/app/OptimisedAliveKeeperTest/RedisClusterSpy.php
@@ -0,0 +1,45 @@
+ */
+ private array $constructorParametersSecond = [];
+
+ public function __construct(
+ ?string $name,
+ ?array $seeds,
+ int|float|null $timeout = null,
+ int|float|null $readTimeout = null,
+ bool $persistent = false,
+ mixed $auth = null,
+ ) {
+ $this->constructorCalls++;
+ }
+
+ public function getConstructorCalls(): int
+ {
+ return $this->constructorCalls;
+ }
+
+ public function ping(array|string $key_or_address, ?string $message = null): mixed
+ {
+ $this->pingCount++;
+
+ return $this->pingCount;
+ }
+
+ public function getPingCount(): int
+ {
+ return $this->pingCount;
+ }
+}
diff --git a/tests/Functional/app/OptimisedAliveKeeperTest/configs/config.yaml b/tests/Functional/app/OptimisedAliveKeeperTest/configs/config.yaml
new file mode 100644
index 0000000..8499e2c
--- /dev/null
+++ b/tests/Functional/app/OptimisedAliveKeeperTest/configs/config.yaml
@@ -0,0 +1,35 @@
+imports:
+ - {resource: ../../config/framework.yaml}
+ - {resource: ../../config/doctrine.yaml}
+
+swoole_bundle_resetter:
+ ping_interval: 10
+ redis_cluster_connections:
+ default: 'RedisCluster'
+
+doctrine:
+ dbal:
+ default_connection: default
+ connections:
+ default:
+ wrapper_class: SwooleBundle\ResetterBundle\Tests\Functional\app\OptimisedAliveKeeperTest\ConnectionMock
+
+services:
+ _defaults:
+ public: false
+ autowire: true
+ autoconfigure: true
+
+ SwooleBundle\ResetterBundle\Tests\Functional\app\HttpRequestLifecycleTest\TestController:
+ public: true
+ arguments:
+ $entityManager: '@doctrine.orm.default_entity_manager'
+
+ RedisCluster:
+ class: SwooleBundle\ResetterBundle\Tests\Functional\app\OptimisedAliveKeeperTest\RedisClusterSpy
+ public: true
+ arguments:
+ $name: 'default'
+ $seeds: [ 'localhost:6379' ]
+ $timeout: 2
+ $readTimeout: 2
diff --git a/tests/Functional/app/OptimisedAliveKeeperTest/configs/routing.yaml b/tests/Functional/app/OptimisedAliveKeeperTest/configs/routing.yaml
new file mode 100644
index 0000000..e69de29
diff --git a/tests/Functional/app/config/bundles.php b/tests/Functional/app/config/bundles.php
new file mode 100644
index 0000000..b918e8a
--- /dev/null
+++ b/tests/Functional/app/config/bundles.php
@@ -0,0 +1,13 @@
+createMock(PlatformAliveKeeper::class);
+ $keeper1->expects(self::once())->method('keepAlive');
+ $keeper2 = $this->createMock(PlatformAliveKeeper::class);
+ $keeper2->expects(self::once())->method('keepAlive');
+
+ $handler = new ConnectionsHandler([$keeper1, $keeper2]);
+ $handler->initialize();
+ }
+}
diff --git a/tests/Unit/DBAL/Connection/DBALPlatformAliveKeeperTest.php b/tests/Unit/DBAL/Connection/DBALPlatformAliveKeeperTest.php
new file mode 100644
index 0000000..bed65b0
--- /dev/null
+++ b/tests/Unit/DBAL/Connection/DBALPlatformAliveKeeperTest.php
@@ -0,0 +1,38 @@
+createMock(Connection::class);
+ $cName2 = 'other';
+ $cMock2 = $this->createMock(Connection::class);
+
+ $keeper1 = $this->createMock(DBALAliveKeeper::class);
+ $keeper1->expects(self::once())->method('keepAlive')->with($cMock1, $cName1);
+ $keeper2 = $this->createMock(DBALAliveKeeper::class);
+ $keeper2->expects(self::once())->method('keepAlive')->with($cMock2, $cName2);
+
+ $platformKeeper = new DBALPlatformAliveKeeper(
+ [
+ $cName1 => $cMock1,
+ $cName2 => $cMock2,
+ ],
+ [
+ $cName1 => $keeper1,
+ $cName2 => $keeper2,
+ ]
+ );
+ $platformKeeper->keepAlive();
+ }
+}
diff --git a/tests/Unit/DBAL/Connection/FailoverAware/FailoverAwareDBALAliveKeeperTest.php b/tests/Unit/DBAL/Connection/FailoverAware/FailoverAwareDBALAliveKeeperTest.php
new file mode 100644
index 0000000..defe261
--- /dev/null
+++ b/tests/Unit/DBAL/Connection/FailoverAware/FailoverAwareDBALAliveKeeperTest.php
@@ -0,0 +1,144 @@
+createMock(LoggerInterface::class);
+ $statementMock = $this->createMock(Result::class);
+ $statementMock->expects(self::atLeast(1))
+ ->method('fetchOne')
+ ->willReturn('0');
+
+ /** @var Connection&MockObject $connectionMock */
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('executeQuery')
+ ->withAnyParameters()
+ ->willReturn($statementMock);
+ $connectionMock->expects(self::exactly(0))->method('close');
+ $connectionMock->expects(self::exactly(0))->method('getNativeConnection');
+
+ $aliveKeeper = new FailoverAwareDBALAliveKeeper($loggerMock);
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+
+ public function testKeepAliveReaderWithoutReconnect(): void
+ {
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $statementMock = $this->createMock(Result::class);
+ $statementMock->expects(self::atLeast(1))
+ ->method('fetchOne')
+ ->willReturn('1');
+
+ /** @var Connection&MockObject $connectionMock */
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('executeQuery')
+ ->withAnyParameters()
+ ->willReturn($statementMock);
+ $connectionMock->expects(self::exactly(0))
+ ->method('close');
+ $connectionMock->expects(self::exactly(0))
+ ->method('getNativeConnection');
+
+ $aliveKeeper = new FailoverAwareDBALAliveKeeper($loggerMock, ConnectionType::READER);
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+
+ public function testKeepAliveWriterWithReconnectOnFailover(): void
+ {
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $loggerMock->expects(self::atLeast(1))
+ ->method('log')
+ ->with(LogLevel::ALERT);
+ $statementMock = $this->createMock(Result::class);
+ $statementMock->expects(self::atLeast(1))
+ ->method('fetchOne')
+ ->willReturn('1');
+
+ /** @var Connection&MockObject $connectionMock */
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('executeQuery')
+ ->withAnyParameters()
+ ->willReturn($statementMock);
+ $connectionMock->expects(self::once())
+ ->method('close');
+ $connectionMock->expects(self::atLeast(1))
+ ->method('getNativeConnection');
+
+ $aliveKeeper = new FailoverAwareDBALAliveKeeper($loggerMock);
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+
+ /**
+ * @throws Exception
+ * @throws MockObjectException
+ */
+ public function testKeepAliveReaderWithReconnectOnFailover(): void
+ {
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $loggerMock->expects(self::atLeast(1))
+ ->method('log')
+ ->with(LogLevel::WARNING);
+ $statementMock = $this->createMock(Result::class);
+ $statementMock->expects(self::atLeast(1))
+ ->method('fetchOne')
+ ->willReturn('0');
+
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('executeQuery')
+ ->withAnyParameters()
+ ->willReturn($statementMock);
+ $connectionMock->expects(self::once())
+ ->method('close');
+ $connectionMock->expects(self::atLeast(1))
+ ->method('getNativeConnection');
+
+ $aliveKeeper = new FailoverAwareDBALAliveKeeper($loggerMock, ConnectionType::READER);
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+
+ public function testKeepAliveWithReconnectConnectionError(): void
+ {
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $loggerMock->expects(self::atLeast(1))
+ ->method('info')
+ ->withAnyParameters();
+ $statementMock = $this->createMock(Result::class);
+ $statementMock->expects(self::atLeast(1))
+ ->method('fetchOne')
+ ->willThrowException($this->createMock(DriverException::class));
+
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('executeQuery')
+ ->withAnyParameters()
+ ->willReturn($statementMock);
+ $connectionMock->expects(self::once())
+ ->method('close');
+ $connectionMock->expects(self::atLeast(1))
+ ->method('getNativeConnection');
+
+ $aliveKeeper = new FailoverAwareDBALAliveKeeper($loggerMock);
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+}
diff --git a/tests/Unit/DBAL/Connection/OptimizedDBALAliveKeeperTest.php b/tests/Unit/DBAL/Connection/OptimizedDBALAliveKeeperTest.php
new file mode 100644
index 0000000..810bba7
--- /dev/null
+++ b/tests/Unit/DBAL/Connection/OptimizedDBALAliveKeeperTest.php
@@ -0,0 +1,34 @@
+createMock(Connection::class);
+ $connectionName = 'default';
+ $decoratedAliveKeepr = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeepr->expects(self::once())
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new OptimizedDBALAliveKeeper($decoratedAliveKeepr, 3);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ sleep(2);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+}
diff --git a/tests/Unit/DBAL/Connection/PassiveIgnoringDBALAliveKeeperTest.php b/tests/Unit/DBAL/Connection/PassiveIgnoringDBALAliveKeeperTest.php
new file mode 100644
index 0000000..6c7f9c4
--- /dev/null
+++ b/tests/Unit/DBAL/Connection/PassiveIgnoringDBALAliveKeeperTest.php
@@ -0,0 +1,77 @@
+createMock(ProxyConnectionMock::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isProxyInitialized')
+ ->willReturn(false);
+ $connectionMock->expects(self::exactly(0))
+ ->method('getDatabasePlatform');
+ $connectionName = 'default';
+
+ $decoratedAliveKeeper = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::exactly(0))
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new PassiveIgnoringDBALAliveKeeper($decoratedAliveKeeper);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+
+ public function testKeepAliveWithoutInitialisedConnectionDoesNotDoAnything(): void
+ {
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isConnected')
+ ->willReturn(false);
+ $connectionMock->expects(self::exactly(0))
+ ->method('getDatabasePlatform');
+ $connectionName = 'default';
+
+ $decoratedAliveKeeper = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::exactly(0))
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new PassiveIgnoringDBALAliveKeeper($decoratedAliveKeeper);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+
+ public function testKeepAliveWithInitialisedConnectionDelegatesControl(): void
+ {
+ /** @var Connection&MockObject $connectionMock */
+ $connectionMock = $this->createMock(ProxyConnectionMock::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isProxyInitialized')
+ ->willReturn(true);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isConnected')
+ ->willReturn(true);
+ $connectionMock->expects(self::exactly(0))
+ ->method('getDatabasePlatform');
+ $connectionName = 'default';
+
+ $decoratedAliveKeeper = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::atLeast(1))
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new PassiveIgnoringDBALAliveKeeper($decoratedAliveKeeper);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+}
diff --git a/tests/Unit/DBAL/Connection/PingingDBALAliveKeeperTest.php b/tests/Unit/DBAL/Connection/PingingDBALAliveKeeperTest.php
new file mode 100644
index 0000000..11e96b5
--- /dev/null
+++ b/tests/Unit/DBAL/Connection/PingingDBALAliveKeeperTest.php
@@ -0,0 +1,74 @@
+createMock(AbstractPlatform::class);
+ $platformMock->expects(self::atLeast(1))
+ ->method('getDummySelectSQL')
+ ->willReturn($query);
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('getDatabasePlatform')
+ ->willReturn($platformMock);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('executeQuery')
+ ->with($query);
+ $connectionMock->expects(self::exactly(0))
+ ->method('close');
+ $connectionMock->expects(self::exactly(0))
+ ->method('getNativeConnection');
+
+ $aliveKeeper = new PingingDBALAliveKeeper();
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+
+ /**
+ * @throws Exception
+ * @throws MockObjectException
+ */
+ public function testKeepAliveWithReconnectOnFailedPing(): void
+ {
+ $query = 'SELECT 1';
+ $platformMock = $this->createMock(AbstractPlatform::class);
+ $platformMock->expects(self::atLeast(1))
+ ->method('getDummySelectSQL')
+ ->willReturn($query);
+ $connectionMock = $this->createMock(Connection::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('getDatabasePlatform')
+ ->willReturn($platformMock);
+ $connLostRefl = new ReflectionClass(ConnectionLost::class);
+ $connectionMock->expects(self::once())
+ ->method('executeQuery')
+ ->with($query)
+ ->willThrowException($connLostRefl->newInstanceWithoutConstructor());
+ $connectionMock->expects(self::atLeast(1))
+ ->method('close');
+ $connectionMock->expects(self::atLeast(1))
+ ->method('getNativeConnection')
+ ->willReturn(true);
+
+ $aliveKeeper = new PingingDBALAliveKeeper();
+ $aliveKeeper->keepAlive($connectionMock, 'default');
+ }
+}
diff --git a/tests/Unit/DBAL/Connection/TransactionDiscardingDBALAliveKeeperTest.php b/tests/Unit/DBAL/Connection/TransactionDiscardingDBALAliveKeeperTest.php
new file mode 100644
index 0000000..93bf92d
--- /dev/null
+++ b/tests/Unit/DBAL/Connection/TransactionDiscardingDBALAliveKeeperTest.php
@@ -0,0 +1,121 @@
+createMock(LoggerInterface::class);
+ $loggerMock->expects(self::atLeast(1))
+ ->method('error')
+ ->with('Connection "default" needed to discard active transaction while running keep-alive routine.');
+ $connectionMock = $this->createMock(ProxyConnectionMock::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isTransactionActive')
+ ->willReturn(true);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('rollBack');
+ $connectionName = 'default';
+
+ $decoratedAliveKeeper = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::atLeast(1))
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new TransactionDiscardingDBALAliveKeeper($decoratedAliveKeeper, $loggerMock);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+
+ public function testRollbackConnectionIfItIsInTransactionAndLogRollbackException(): void
+ {
+ $connectionName = 'default';
+ $exceptionMock = $this->createMock(Throwable::class);
+
+ $matcher = self::exactly(2);
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $loggerMock->expects($matcher)
+ ->method('error')
+ ->with(
+ $this->callback(function ($value) use ($matcher, $connectionName) {
+ match ($matcher->numberOfInvocations()) {
+ 1 => $this->assertEquals(
+ sprintf(
+ 'Connection "%s" needed to discard active transaction while running '
+ . 'keep-alive routine.',
+ $connectionName,
+ ),
+ $value
+ ),
+ 2 => $this->assertEquals(
+ sprintf(
+ 'An error occurred while discarding active transaction in connection "%s".',
+ $connectionName,
+ ),
+ $value
+ ),
+ };
+
+ return true;
+ }),
+ $this->callback(static function ($value) use ($matcher, $exceptionMock) {
+ match ($matcher->numberOfInvocations()) {
+ 1 => true,
+ 2 => self::assertEquals($value, ['exception' => $exceptionMock]),
+ };
+
+ return true;
+ })
+ );
+
+ $connectionMock = $this->createMock(ProxyConnectionMock::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isTransactionActive')
+ ->willReturn(true);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('rollBack')
+ ->willThrowException($exceptionMock);
+
+ $decoratedAliveKeeper = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::atLeast(1))
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new TransactionDiscardingDBALAliveKeeper($decoratedAliveKeeper, $loggerMock);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+
+ public function testDoNotRollbackConnectionIfItIsNotInTransaction(): void
+ {
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $loggerMock->expects(self::exactly(0))
+ ->method('error')
+ ->withAnyParameters();
+
+ $connectionMock = $this->createMock(ProxyConnectionMock::class);
+ $connectionMock->expects(self::atLeast(1))
+ ->method('isTransactionActive')
+ ->willReturn(false);
+ $connectionMock->expects(self::exactly(0))
+ ->method('rollBack');
+
+ $connectionName = 'default';
+
+ $decoratedAliveKeeper = $this->createMock(DBALAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::atLeast(1))
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new TransactionDiscardingDBALAliveKeeper($decoratedAliveKeeper, $loggerMock);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+}
diff --git a/tests/Unit/Helper/ProxyConnectionMock.php b/tests/Unit/Helper/ProxyConnectionMock.php
new file mode 100644
index 0000000..718b465
--- /dev/null
+++ b/tests/Unit/Helper/ProxyConnectionMock.php
@@ -0,0 +1,57 @@
+ $params
+ */
+ public function __construct(
+ array $params,
+ Driver $driver,
+ ?Configuration $config = null,
+ ?EventManager $eventManager = null,
+ ) {}
+
+ public function setProxyInitializer(?Closure $initializer = null): void {}
+
+ public function getProxyInitializer(): ?Closure
+ {
+ return null;
+ }
+
+ public function initializeProxy(): bool
+ {
+ return false;
+ }
+
+ public function isProxyInitialized(): bool
+ {
+ return false;
+ }
+
+ public function getWrappedValueHolderValue(): ?object
+ {
+ return null;
+ }
+
+ public function isTransactionActive(): bool
+ {
+ return false;
+ }
+
+ public function rollBack(): void {}
+}
diff --git a/tests/Unit/ORM/ResettableEntityManagerTest.php b/tests/Unit/ORM/ResettableEntityManagerTest.php
new file mode 100644
index 0000000..067cf3f
--- /dev/null
+++ b/tests/Unit/ORM/ResettableEntityManagerTest.php
@@ -0,0 +1,88 @@
+createMock(ObjectRepository::class);
+ } else {
+ $repositoryMock = $this->createMock(EntityRepository::class);
+ }
+ $repositoryFactoryMock = $this->createMock(RepositoryFactory::class);
+ $repositoryFactoryMock->expects(self::once())
+ ->method('getRepository')
+ ->with($this->callback(static function ($value) {
+ self::assertInstanceOf(ResettableEntityManager::class, $value);
+
+ return true;
+ }))
+ ->willReturn($repositoryMock);
+ $configurationMock = $this->createMock(Configuration::class);
+ $configurationMock->expects(self::once())
+ ->method('getRepositoryFactory')
+ ->willReturn($repositoryFactoryMock);
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $registryMock = $this->createMock(RegistryInterface::class);
+
+ $em = new ResettableEntityManager($configurationMock, $emMock, $registryMock, 'default');
+
+ $em->getRepository(TestEntity::class);
+ }
+
+ public function testClearOrResetIfNeededShouldClearWhenWrappedIsOpen(): void
+ {
+ $configurationMock = $this->createMock(Configuration::class);
+ $configurationMock->expects(self::atLeast(1))
+ ->method('getRepositoryFactory')
+ ->willReturn($this->createMock(RepositoryFactory::class));
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $emMock->expects(self::atLeast(1))
+ ->method('isOpen')
+ ->willReturn(true);
+ $emMock->expects(self::atLeast(1))
+ ->method('clear')
+ ->with();
+ $registryMock = $this->createMock(RegistryInterface::class);
+
+ $em = new ResettableEntityManager($configurationMock, $emMock, $registryMock, 'default');
+ $em->clearOrResetIfNeeded();
+ }
+
+ public function testClearOrResetIfNeededShouldResetWhenWrappedIsClosed(): void
+ {
+ $decoratedName = 'default';
+ $configurationMock = $this->createMock(Configuration::class);
+ $configurationMock->expects(self::atLeast(1))
+ ->method('getRepositoryFactory')
+ ->willReturn($this->createMock(RepositoryFactory::class));
+ $emMock = $this->createMock(EntityManagerInterface::class);
+ $emMock->expects(self::atLeast(1))
+ ->method('isOpen')
+ ->willReturn(false);
+ $registryMock = $this->createMock(RegistryInterface::class);
+ $registryMock->expects(self::atLeast(1))
+ ->method('resetManager')
+ ->with($decoratedName)
+ ->willReturn($this->createMock(ResettableEntityManager::class));
+
+ $em = new ResettableEntityManager($configurationMock, $emMock, $registryMock, $decoratedName);
+
+ $em->clearOrResetIfNeeded();
+ }
+}
diff --git a/tests/Unit/Redis/Cluster/Connection/OptimizedRedisClusterAliveKeeperTest.php b/tests/Unit/Redis/Cluster/Connection/OptimizedRedisClusterAliveKeeperTest.php
new file mode 100644
index 0000000..b96876b
--- /dev/null
+++ b/tests/Unit/Redis/Cluster/Connection/OptimizedRedisClusterAliveKeeperTest.php
@@ -0,0 +1,34 @@
+createMock(RedisCluster::class);
+ $connectionName = 'default';
+ $decoratedAliveKeepr = $this->createMock(RedisClusterAliveKeeper::class);
+ $decoratedAliveKeepr->expects(self::once())
+ ->method('keepAlive')
+ ->with($connectionMock, $connectionName);
+
+ $aliveKeeper = new OptimizedRedisClusterAliveKeeper($decoratedAliveKeepr, 3);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ sleep(2);
+ $aliveKeeper->keepAlive($connectionMock, $connectionName);
+ }
+}
diff --git a/tests/Unit/Redis/Cluster/Connection/PassiveIgnoringRedisClusterAliveKeeperTest.php b/tests/Unit/Redis/Cluster/Connection/PassiveIgnoringRedisClusterAliveKeeperTest.php
new file mode 100644
index 0000000..36af22a
--- /dev/null
+++ b/tests/Unit/Redis/Cluster/Connection/PassiveIgnoringRedisClusterAliveKeeperTest.php
@@ -0,0 +1,44 @@
+createMock(RedisClusterSpy::class);
+ $clusterMock->expects(self::atLeast(1))
+ ->method('isProxyInitialized')
+ ->willReturn(false);
+ $connectionName = 'default';
+ $decoratedAliveKeeper = $this->createMock(RedisClusterAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::exactly(0))
+ ->method('keepAlive')
+ ->with($clusterMock, $connectionName);
+
+ $aliveKeeper = new PassiveIgnoringRedisClusterAliveKeeper($decoratedAliveKeeper);
+ $aliveKeeper->keepAlive($clusterMock, $connectionName);
+ }
+
+ public function testKeepAliveWithInitialisedConnectionDelegatesControl(): void
+ {
+ $clusterMock = $this->createMock(RedisClusterSpy::class);
+ $clusterMock->expects(self::atLeast(1))
+ ->method('isProxyInitialized')
+ ->willReturn(true);
+ $connectionName = 'default';
+ $decoratedAliveKeeper = $this->createMock(RedisClusterAliveKeeper::class);
+ $decoratedAliveKeeper->expects(self::atLeast(1))
+ ->method('keepAlive')
+ ->with($clusterMock, $connectionName);
+
+ $aliveKeeper = new PassiveIgnoringRedisClusterAliveKeeper($decoratedAliveKeeper);
+ $aliveKeeper->keepAlive($clusterMock, $connectionName);
+ }
+}
diff --git a/tests/Unit/Redis/Cluster/Connection/PingingRedisClusterAliveKeeperTest.php b/tests/Unit/Redis/Cluster/Connection/PingingRedisClusterAliveKeeperTest.php
new file mode 100644
index 0000000..ed87e63
--- /dev/null
+++ b/tests/Unit/Redis/Cluster/Connection/PingingRedisClusterAliveKeeperTest.php
@@ -0,0 +1,47 @@
+createMock(LoggerInterface::class);
+ $clusterMock = $this->createMock(RedisCluster::class);
+ $clusterMock->expects(self::atLeast(1))
+ ->method('ping')
+ ->with('hello')
+ ->willReturn('hello');
+ $aliveKeeper = new PingingRedisClusterAliveKeeper([], $loggerMock);
+ $aliveKeeper->keepAlive($clusterMock, 'default');
+ }
+
+ public function testKeepAliveWithReconnectOnFailedPing(): void
+ {
+ $constructorParameters = [
+ 'session',
+ ['localhost:6379'],
+ 2,
+ 2,
+ ];
+
+ $clusterSpy = new RedisClusterSpy(...$constructorParameters);
+ $loggerMock = $this->createMock(LoggerInterface::class);
+ $loggerMock->expects(self::atLeast(1))
+ ->method('info')
+ ->with("Exceptional reconnect for redis cluster connection 'default'");
+
+ $aliveKeeper = new PingingRedisClusterAliveKeeper($constructorParameters, $loggerMock);
+ $aliveKeeper->keepAlive($clusterSpy, 'default');
+
+ self::assertTrue($clusterSpy->wasConstructorCalled());
+ self::assertSame($constructorParameters, $clusterSpy->getConstructorParametersSecond());
+ }
+}
diff --git a/tests/Unit/Redis/Cluster/Connection/RedisClusterPlatformAliveKeeperTest.php b/tests/Unit/Redis/Cluster/Connection/RedisClusterPlatformAliveKeeperTest.php
new file mode 100644
index 0000000..f98334f
--- /dev/null
+++ b/tests/Unit/Redis/Cluster/Connection/RedisClusterPlatformAliveKeeperTest.php
@@ -0,0 +1,41 @@
+createMock(RedisCluster::class);
+ $cName2 = 'other';
+ $cMock2 = $this->createMock(RedisCluster::class);
+
+ $keeper1 = $this->createMock(RedisClusterAliveKeeper::class);
+ $keeper1->expects(self::once())
+ ->method('keepAlive')
+ ->with($cMock1, $cName1);
+ $keeper2 = $this->createMock(RedisClusterAliveKeeper::class);
+ $keeper2->method('keepAlive')
+ ->with($cMock2, $cName2);
+
+ $platformKeeper = new RedisClusterPlatformAliveKeeper(
+ [
+ $cName1 => $cMock1,
+ $cName2 => $cMock2,
+ ],
+ [
+ $cName1 => $keeper1,
+ $cName2 => $keeper2,
+ ]
+ );
+ $platformKeeper->keepAlive();
+ }
+}
diff --git a/tests/Unit/Redis/Cluster/Connection/RedisClusterSpy.php b/tests/Unit/Redis/Cluster/Connection/RedisClusterSpy.php
new file mode 100644
index 0000000..831f16c
--- /dev/null
+++ b/tests/Unit/Redis/Cluster/Connection/RedisClusterSpy.php
@@ -0,0 +1,99 @@
+ */
+ private array $constructorParametersFirst = [];
+
+ /** @var array */
+ private array $constructorParametersSecond = [];
+
+ private bool $initialized = true;
+
+ public function __construct(
+ ?string $name,
+ ?array $seeds,
+ int|float|null $timeout = null,
+ int|float|null $readTimeout = null,
+ bool $persistent = false,
+ mixed $auth = null,
+ ) {
+ $this->constructorCalls++;
+
+ if ($this->constructorCalls === 1) {
+ $this->constructorParametersFirst = [$name, $seeds, $timeout, $readTimeout];
+ } elseif ($this->constructorCalls > 1) {
+ $this->wasConstructorCalled = true;
+ $this->constructorParametersSecond = [$name, $seeds, $timeout, $readTimeout];
+ }
+ }
+
+ public function wasConstructorCalled(): bool
+ {
+ return $this->wasConstructorCalled;
+ }
+
+ /**
+ * @return array
+ */
+ public function getConstructorParametersFirst(): array
+ {
+ return $this->constructorParametersFirst;
+ }
+
+ /**
+ * @return array
+ */
+ public function getConstructorParametersSecond(): array
+ {
+ return $this->constructorParametersSecond;
+ }
+
+ public function ping(array|string $key_or_address, ?string $message = null): mixed
+ {
+ throw new RedisClusterException('Test exception');
+ }
+
+ public function setProxyInitializer(?Closure $initializer = null): void {}
+
+ public function getProxyInitializer(): ?Closure
+ {
+ return null;
+ }
+
+ public function initializeProxy(): bool
+ {
+ return true;
+ }
+
+ public function isProxyInitialized(): bool
+ {
+ return $this->initialized;
+ }
+
+ public function setIsProxyInitialized(bool $initialised = true): bool
+ {
+ return $this->initialized = $initialised;
+ }
+
+ public function getWrappedValueHolderValue(): ?object
+ {
+ return null;
+ }
+}
diff --git a/tests/allowed.json b/tests/allowed.json
new file mode 100644
index 0000000..4bc4b86
--- /dev/null
+++ b/tests/allowed.json
@@ -0,0 +1,12 @@
+[
+ {
+ "location": "SwooleBundle\\ResetterBundle\\Tests\\Functional\\FailoverAwareTest::testFailoverAliveKeeperOnRequestStartIsNotActivatedIfConnectionIsNotOpen",
+ "message": "The \"Doctrine\\DBAL\\Result::__construct()\" method is considered internal The result can be only instantiated by {@see Connection} or {@see Statement}. It may change without further notice. You should not extend it from \"Doctrine\\DBAL\\Result@anonymous\".",
+ "count": 1
+ },
+ {
+ "location": "SwooleBundle\\ResetterBundle\\Tests\\Unit\\DBAL\\Connection\\PassiveIgnoringDBALAliveKeeperTest::testKeepAliveWithoutInitialisedConnectionProxyDoesNotDoAnything",
+ "message": "The \"Doctrine\\DBAL\\Connection::__construct()\" method is considered internal The connection can be only instantiated by the driver manager. It may change without further notice. You should not extend it from \"SwooleBundle\\ResetterBundle\\Tests\\Unit\\Helper\\ProxyConnectionMock\".",
+ "count": 1
+ }
+]