diff --git a/composer.json b/composer.json index 5652d285..4ebc1d36 100644 --- a/composer.json +++ b/composer.json @@ -19,7 +19,7 @@ ], "require": { "php": "~8.2.0 || ~8.3.0", - "patchlevel/event-sourcing": "^3.9.0", + "patchlevel/event-sourcing": "^3.10.0", "symfony/cache": "^6.4.0|^7.0.0", "symfony/config": "^6.4.0|^7.0.0", "symfony/console": "^6.4.1|^7.0.1", diff --git a/composer.lock b/composer.lock index 4b448420..a7c9c7a5 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "23268bcf3b36fdea578a33c77056eade", + "content-hash": "8877397a40ab62c2e6c5fbb9a51301f2", "packages": [ { "name": "brick/math", @@ -413,16 +413,16 @@ }, { "name": "patchlevel/event-sourcing", - "version": "3.9.0", + "version": "3.10.0", "source": { "type": "git", "url": "https://github.com/patchlevel/event-sourcing.git", - "reference": "d0ea8503ff083228ba5caa8135ede0f0593d8f54" + "reference": "0f1656bb4221e4b78df087c934b30a6346a3e7c8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/d0ea8503ff083228ba5caa8135ede0f0593d8f54", - "reference": "d0ea8503ff083228ba5caa8135ede0f0593d8f54", + "url": "https://api.github.com/repos/patchlevel/event-sourcing/zipball/0f1656bb4221e4b78df087c934b30a6346a3e7c8", + "reference": "0f1656bb4221e4b78df087c934b30a6346a3e7c8", "shasum": "" }, "require": { @@ -430,7 +430,7 @@ "doctrine/migrations": "^3.3.2", "patchlevel/hydrator": "^1.5.0", "patchlevel/worker": "^1.4.0", - "php": "~8.2.0 || ~8.3.0", + "php": "~8.2.0 || ~8.3.0 || ~8.4.0", "psr/cache": "^2.0.0|^3.0.0", "psr/clock": "^1.0", "psr/container": "^2.0", @@ -443,22 +443,22 @@ "symfony/type-info": "^7.2" }, "require-dev": { - "cspray/phinal": "^2.0.0", + "cspray/phinal": "dev-main#9826c3407056a4618f8bba303800403e47ccb3a7", "doctrine/orm": "^2.18.0|^3.0.0", - "ext-pdo_sqlite": "~8.2.0 || ~8.3.0", - "infection/infection": "^0.27.10", + "ext-pdo_sqlite": "~8.2.0 || ~8.3.0 || ~8.4.0", + "infection/infection": "^0.29.10", "league/commonmark": "^2.4", "patchlevel/coding-standard": "^1.3.0", - "patchlevel/event-sourcing-psalm-plugin": "^3.0.0", + "patchlevel/event-sourcing-psalm-plugin": "^3.1.0", "phpbench/phpbench": "^1.2.15", "phpspec/prophecy-phpunit": "^2.1.0", "phpstan/phpstan": "^2.1.0", - "phpunit/phpunit": "^10.5.2", + "phpunit/phpunit": "^11.5.7", "psalm/plugin-phpunit": "^0.19.0", - "roave/infection-static-analysis-plugin": "^1.34.0", + "roave/infection-static-analysis-plugin": "^1.36.0", "symfony/messenger": "^5.4.31|^6.4.0|^7.0.1", "symfony/var-dumper": "^5.4.29|^6.4.0|^7.0.0", - "vimeo/psalm": "^5.17.0", + "vimeo/psalm": "^6.0.0", "wnx/commonmark-markdown-renderer": "^1.4" }, "suggest": { @@ -492,9 +492,9 @@ ], "support": { "issues": "https://github.com/patchlevel/event-sourcing/issues", - "source": "https://github.com/patchlevel/event-sourcing/tree/3.9.0" + "source": "https://github.com/patchlevel/event-sourcing/tree/3.10.0" }, - "time": "2025-02-07T12:02:04+00:00" + "time": "2025-02-19T01:58:04+00:00" }, { "name": "patchlevel/hydrator", @@ -7765,12 +7765,12 @@ "source": { "type": "git", "url": "https://github.com/Roave/SecurityAdvisories.git", - "reference": "3dafa2bcf6675854ed6410d1c84c0f71f819fc26" + "reference": "70eb886a27427421cf1bd612067810c9fb1cbb5c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/3dafa2bcf6675854ed6410d1c84c0f71f819fc26", - "reference": "3dafa2bcf6675854ed6410d1c84c0f71f819fc26", + "url": "https://api.github.com/repos/Roave/SecurityAdvisories/zipball/70eb886a27427421cf1bd612067810c9fb1cbb5c", + "reference": "70eb886a27427421cf1bd612067810c9fb1cbb5c", "shasum": "" }, "conflict": { @@ -7862,7 +7862,7 @@ "cesnet/simplesamlphp-module-proxystatistics": "<3.1", "chriskacerguis/codeigniter-restserver": "<=2.7.1", "civicrm/civicrm-core": ">=4.2,<4.2.9|>=4.3,<4.3.3", - "ckeditor/ckeditor": "<4.24", + "ckeditor/ckeditor": "<4.25", "cockpit-hq/cockpit": "<2.7|==2.7", "codeception/codeception": "<3.1.3|>=4,<4.1.22", "codeigniter/framework": "<3.1.9", @@ -8631,7 +8631,7 @@ "type": "tidelift" } ], - "time": "2025-02-14T21:04:39+00:00" + "time": "2025-02-18T20:05:22+00:00" }, { "name": "sanmai/later", @@ -10776,16 +10776,16 @@ }, { "name": "thecodingmachine/safe", - "version": "v3.0.0", + "version": "v3.0.1", "source": { "type": "git", "url": "https://github.com/thecodingmachine/safe.git", - "reference": "1b99e649415872997e43c771c55ba9b08d601329" + "reference": "37123c3b729fb10dc35f91fa1ba9cec9c28cb393" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/1b99e649415872997e43c771c55ba9b08d601329", - "reference": "1b99e649415872997e43c771c55ba9b08d601329", + "url": "https://api.github.com/repos/thecodingmachine/safe/zipball/37123c3b729fb10dc35f91fa1ba9cec9c28cb393", + "reference": "37123c3b729fb10dc35f91fa1ba9cec9c28cb393", "shasum": "" }, "require": { @@ -10895,7 +10895,7 @@ "description": "PHP core functions that throw exceptions instead of returning FALSE on error", "support": { "issues": "https://github.com/thecodingmachine/safe/issues", - "source": "https://github.com/thecodingmachine/safe/tree/v3.0.0" + "source": "https://github.com/thecodingmachine/safe/tree/v3.0.1" }, "funding": [ { @@ -10911,7 +10911,7 @@ "type": "github" } ], - "time": "2025-02-11T00:27:22+00:00" + "time": "2025-02-18T02:09:20+00:00" }, { "name": "theseer/tokenizer", diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index d7063da5..1b0f0349 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -13,7 +13,9 @@ * command_bus: array{enabled: bool, service: string}, * subscription: array{ * store: array{type: string, service: string|null}, - * retry_strategy: array{base_delay: int, delay_factor: int, max_attempts: int}, + * retry_strategy?: array{base_delay: int, delay_factor: int, max_attempts: int}, + * retry_strategies: array}>, + * default_retry_strategy: string, * catch_up: array{enabled: bool, limit: positive-int|null}, * throw_on_error: array{enabled: bool}, * run_after_aggregate_save: array{ @@ -182,7 +184,11 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->arrayNode('retry_strategy') - ->addDefaultsIfNotSet() + ->setDeprecated( + 'patchlevel/event-sourcing-bundle', + '3.10', + 'The "%node%" option is deprecated and will be removed in 4.0. Use "patchlevel_event_sourcing.subscription.retry_strategies" instead.' + ) ->children() ->integerNode('base_delay')->defaultValue(5)->end() ->integerNode('delay_factor')->defaultValue(2)->end() @@ -190,6 +196,32 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() + ->arrayNode('retry_strategies') + ->useAttributeAsKey('name') + ->arrayPrototype() + ->children() + ->enumNode('type')->values(['clock_based', 'no_retry', 'custom'])->end() + ->scalarNode('service')->end() + ->arrayNode('options')->variablePrototype()->end()->end() + ->end() + ->end() + ->defaultValue([ + 'default' => [ + 'type' => 'clock_based', + 'options' => [ + 'base_delay' => 5, + 'delay_factor' => 2, + 'max_attempts' => 5, + ], + ], + 'no_retry' => [ + 'type' => 'no_retry', + ], + ]) + ->end() + + ->scalarNode('default_retry_strategy')->defaultValue('default')->end() + ->arrayNode('catch_up') ->canBeEnabled() ->addDefaultsIfNotSet() diff --git a/src/DependencyInjection/PatchlevelEventSourcingExtension.php b/src/DependencyInjection/PatchlevelEventSourcingExtension.php index d494d4e6..25108f5a 100644 --- a/src/DependencyInjection/PatchlevelEventSourcingExtension.php +++ b/src/DependencyInjection/PatchlevelEventSourcingExtension.php @@ -97,7 +97,9 @@ use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; @@ -335,16 +337,73 @@ static function (ChildDefinition $definition): void { $container->register(AttributeSubscriberMetadataFactory::class); $container->setAlias(SubscriberMetadataFactory::class, AttributeSubscriberMetadataFactory::class); - $container->register(ClockBasedRetryStrategy::class) + $strategies = []; + + $retryStrategy = $config['subscription']['retry_strategy'] ?? null; + + if ($retryStrategy) { + $container->register(ClockBasedRetryStrategy::class) + ->setArguments([ + new Reference('event_sourcing.clock'), + $retryStrategy['base_delay'], + $retryStrategy['delay_factor'], + $retryStrategy['max_attempts'], + ]); + + $container->register(NoRetryStrategy::class); + + $container + ->setAlias(RetryStrategy::class, ClockBasedRetryStrategy::class) + ->setDeprecated( + 'patchlevel/event-sourcing-bundle', + '3.10', + 'The "%alias_id%" alias is deprecated, use "RetryStrategyRepository" instead.', + ); + + $strategies['default'] = new Reference(RetryStrategy::class); + $strategies['no_retry'] = new Reference(NoRetryStrategy::class); + } else { + foreach ($config['subscription']['retry_strategies'] as $name => $strategyConfig) { + if ($strategyConfig['type'] === 'custom') { + $strategies[$name] = new Reference($strategyConfig['service']); + + continue; + } + + $id = 'event_sourcing.subscription.retry_strategy.' . $name; + + if ($strategyConfig['type'] === 'clock_based') { + $container->register($id, ClockBasedRetryStrategy::class) + ->setArguments([ + new Reference('event_sourcing.clock'), + $strategyConfig['options']['base_delay'] ?? 5, + $strategyConfig['options']['delay_factor'] ?? 2, + $strategyConfig['options']['max_attempts'] ?? 5, + ]); + + $strategies[$name] = new Reference($id); + + continue; + } + + if ($strategyConfig['type'] === 'no_retry') { + $container->register($id, NoRetryStrategy::class); + + $strategies[$name] = new Reference($id); + + continue; + } + + throw new InvalidArgumentException(sprintf('Unknown retry strategy type "%s"', $strategyConfig['type'])); + } + } + + $container->register(RetryStrategyRepository::class) ->setArguments([ - new Reference('event_sourcing.clock'), - $config['subscription']['retry_strategy']['base_delay'], - $config['subscription']['retry_strategy']['delay_factor'], - $config['subscription']['retry_strategy']['max_attempts'], + $strategies, + $config['subscription']['default_retry_strategy'], ]); - $container->setAlias(RetryStrategy::class, ClockBasedRetryStrategy::class); - $container->register(SubscriberHelper::class) ->setArguments([new Reference(SubscriberMetadataFactory::class)]); @@ -395,7 +454,7 @@ static function (ChildDefinition $definition): void { new Reference(Store::class), new Reference(SubscriptionStore::class), new Reference(SubscriberAccessorRepository::class), - new Reference(RetryStrategy::class), + new Reference(RetryStrategyRepository::class), new Reference('logger', ContainerInterface::NULL_ON_INVALID_REFERENCE), ]) ->addTag('monolog.logger', ['channel' => 'event_sourcing']); diff --git a/tests/Unit/PatchlevelEventSourcingBundleTest.php b/tests/Unit/PatchlevelEventSourcingBundleTest.php index 71487846..62645772 100644 --- a/tests/Unit/PatchlevelEventSourcingBundleTest.php +++ b/tests/Unit/PatchlevelEventSourcingBundleTest.php @@ -66,6 +66,10 @@ use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; @@ -96,7 +100,6 @@ use Psr\EventDispatcher\EventDispatcherInterface; use Psr\Log\LoggerInterface; use Psr\SimpleCache\CacheInterface; -use stdClass; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\DependencyInjection\Argument\TaggedIteratorArgument; use Symfony\Component\DependencyInjection\ContainerBuilder; @@ -1129,8 +1132,7 @@ public function testAutoconfigureArgumentResolver(): void $container = new ContainerBuilder(); $container->setDefinition(DummyArgumentResolver::class, new Definition(DummyArgumentResolver::class)) - ->setAutoconfigured(true) - ; + ->setAutoconfigured(true); $this->compileContainer( $container, @@ -1144,8 +1146,110 @@ public function testAutoconfigureArgumentResolver(): void ); self::assertTrue($container->getDefinition(DummyArgumentResolver::class)->hasTag('event_sourcing.argument_resolver')); - self::assertInstanceOf(TaggedIteratorArgument::class, $container->getDefinition(MetadataSubscriberAccessorRepository::class)->getArgument(2)); - self::assertEquals('event_sourcing.argument_resolver', $container->getDefinition(MetadataSubscriberAccessorRepository::class)->getArgument(2)->getTag()); + self::assertInstanceOf(TaggedIteratorArgument::class, + $container->getDefinition(MetadataSubscriberAccessorRepository::class)->getArgument(2)); + self::assertEquals('event_sourcing.argument_resolver', + $container->getDefinition(MetadataSubscriberAccessorRepository::class)->getArgument(2)->getTag()); + } + + public function testLegacyRetryStrategy(): void + { + $container = new ContainerBuilder(); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => [ + 'service' => 'doctrine.dbal.eventstore_connection', + ], + 'subscription' => [ + 'retry_strategy' => [ + 'base_delay' => 10, + 'delay_factor' => 11, + 'max_attempts' => 12, + ], + ], + ], + ] + ); + + $repository = $container->get(RetryStrategyRepository::class); + + self::assertInstanceOf(RetryStrategyRepository::class, $repository); + self::assertInstanceOf(ClockBasedRetryStrategy::class, $repository->getDefaultRetryStrategy()); + self::assertInstanceOf(ClockBasedRetryStrategy::class, $repository->get('default')); + self::assertInstanceOf(NoRetryStrategy::class, $repository->get('no_retry')); + } + + public function testRetryStrategy(): void + { + $container = new ContainerBuilder(); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => [ + 'service' => 'doctrine.dbal.eventstore_connection', + ], + 'subscription' => [ + 'retry_strategies' => [ + 'default' => [ + 'type' => 'clock_based', + 'options' => [ + 'base_delay' => 10, + 'delay_factor' => 11, + 'max_attempts' => 12, + ], + ], + 'no_retry' => [ + 'type' => 'no_retry', + ] + ] + ], + ], + ] + ); + + $repository = $container->get(RetryStrategyRepository::class); + + self::assertInstanceOf(RetryStrategyRepository::class, $repository); + self::assertInstanceOf(ClockBasedRetryStrategy::class, $repository->getDefaultRetryStrategy()); + self::assertInstanceOf(ClockBasedRetryStrategy::class, $repository->get('default')); + self::assertInstanceOf(NoRetryStrategy::class, $repository->get('no_retry')); + } + + public function testRetryStrategyCustom(): void + { + $retryStrategy = $this->prophesize(RetryStrategy::class)->reveal(); + + $container = new ContainerBuilder(); + $container->set('my_retry_strategy', $retryStrategy); + + $this->compileContainer( + $container, + [ + 'patchlevel_event_sourcing' => [ + 'connection' => [ + 'service' => 'doctrine.dbal.eventstore_connection', + ], + 'subscription' => [ + 'retry_strategies' => [ + 'default' => [ + 'type' => 'custom', + 'service' => 'my_retry_strategy' + ], + ] + ], + ], + ] + ); + + $repository = $container->get(RetryStrategyRepository::class); + + self::assertInstanceOf(RetryStrategyRepository::class, $repository); + self::assertEquals($retryStrategy, $repository->getDefaultRetryStrategy()); } public function testSchemaMerge(): void