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 + } +]