diff --git a/docker-compose.yaml b/docker-compose.yaml index 38249c8b5..2174d030d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,15 @@ +# docker run --rm -it --volume $PWD:/app --net="host" -w /app ghcr.io/patchlevel/php:8.5 services: + php: + image: ghcr.io/patchlevel/php:8.5 + volumes: + - .:/app + working_dir: /app + network_mode: host + tty: true + stdin_open: true + command: sleep infinity + postgres: image: postgres:alpine environment: @@ -13,4 +24,4 @@ services: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore ports: - - 3306:3306 \ No newline at end of file + - 3306:3306 diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 56ec77a5f..9efd02bf8 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -86,6 +86,60 @@ $subscriptionEngine = new DefaultSubscriptionEngine( RetryStrategyRepository::withDefault($retryStrategy), ); ``` +### Subscription Engine Commands + +The `SubscriptionEngine` interface has been changed. +The methods `setup`, `boot`, `run`, `teardown`, `remove`, `reactivate`, `pause` and `refresh` have been replaced +by a single `execute` method that takes a command object. +The `ids` and `groups` filters, previously passed via `SubscriptionEngineCriteria`, +are now constructor parameters of the command objects. +The `SubscriptionEngineCriteria` is now only used for the `subscriptions` method. + +before: + +```php +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; + +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->setup(new SubscriptionEngineCriteria(ids: ['profile_1']), skipBooting: true); +$subscriptionEngine->boot(new SubscriptionEngineCriteria(ids: ['profile_1']), limit: 100); +$subscriptionEngine->run(new SubscriptionEngineCriteria(ids: ['profile_1']), limit: 100); +$subscriptionEngine->teardown(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->remove(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->reactivate(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->pause(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->refresh(new SubscriptionEngineCriteria(ids: ['profile_1'])); +``` +after: + +```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; + +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->execute(new Setup(ids: ['profile_1'], skipBooting: true)); +$subscriptionEngine->execute(new Boot(ids: ['profile_1'], limit: 100)); +$subscriptionEngine->execute(new Run(ids: ['profile_1'], limit: 100)); +$subscriptionEngine->execute(new Teardown(ids: ['profile_1'])); +$subscriptionEngine->execute(new Remove(ids: ['profile_1'])); +$subscriptionEngine->execute(new Reactivate(ids: ['profile_1'])); +$subscriptionEngine->execute(new Pause(ids: ['profile_1'])); +$subscriptionEngine->execute(new Refresh(ids: ['profile_1'])); +``` +Further changes: + +* The `CanRefreshSubscriptions` interface has been removed. Refresh is now part of the `SubscriptionEngine` interface via the `Refresh` command. +* `ProcessedResult` now extends `Result`, so the `execute` method always returns a `Result`. The `Boot` and `Run` commands return a `ProcessedResult`. +* The `DefaultSubscriptionEngine` accepts an optional `EventDispatcherInterface` as last constructor argument to hook into the engine with own listeners. + ## Store ### StreamStore diff --git a/docs/cli.md b/docs/cli.md index c2f86d906..b1b4b6e0b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,6 +34,7 @@ To manage your subscriptions there are the following cli commands. * SubscriptionBootCommand: `event-sourcing:subscription:boot` * SubscriptionPauseCommand: `event-sourcing:subscription:pause` * SubscriptionReactiveCommand: `event-sourcing:subscription:reactive` +* SubscriptionRefreshCommand: `event-sourcing:subscription:refresh` * SubscriptionRemoveCommand: `event-sourcing:subscription:remove` * SubscriptionRunCommand: `event-sourcing:subscription:run` * SubscriptionSetupCommand: `event-sourcing:subscription:setup` @@ -86,6 +87,7 @@ $cli->addCommands([ new Command\SubscriptionTeardownCommand($subscriptionEngine), new Command\SubscriptionRemoveCommand($subscriptionEngine), new Command\SubscriptionReactivateCommand($subscriptionEngine), + new Command\SubscriptionRefreshCommand($subscriptionEngine), new Command\SubscriptionSetupCommand($subscriptionEngine), new Command\SubscriptionStatusCommand($subscriptionEngine), new Command\SchemaCreateCommand($schemaDirector), diff --git a/docs/getting-started.md b/docs/getting-started.md index fd8e6ea65..baa3819c4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -339,6 +339,7 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; @@ -358,7 +359,7 @@ $schemaDirector = new DoctrineSchemaDirector( $schemaDirector->create(); /** @var SubscriptionEngine $engine */ -$engine->setup(skipBooting: true); +$engine->execute(new Setup(skipBooting: true)); ``` :::note diff --git a/docs/subscription.md b/docs/subscription.md index 2601babf7..b9c367d87 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1142,7 +1142,8 @@ $subscriberAccessorRepository = new MetadataSubscriberAccessorRepository([ Now we can create the subscription engine and plug together the necessary services. The message loader is needed to load the messages, the Subscription Store to store the subscription state and we need the subscriber accessor repository. Optionally, we can also pass a retry strategy. -Finally, if we want to use the cleanup feature, we need to pass the cleanup handlers. +If we want to use the cleanup feature, we need to pass the cleanup handlers. +Finally, we can pass an event dispatcher to hook into the engine with own listeners. ```php use Doctrine\DBAL\Connection; @@ -1153,6 +1154,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; +use Symfony\Component\EventDispatcher\EventDispatcher; /** * @var MessageLoader $messageLoader @@ -1169,6 +1171,34 @@ $subscriptionEngine = new DefaultSubscriptionEngine( $retryStrategyRepository, // optional, if not set the default retry strategy is used $logger, // optional new DefaultCleaner([new DbalCleanupTaskHandler($projectionConnection)]), // optional but required if you want to use the cleanup feature + new EventDispatcher(), // optional, to hook into the engine with own listeners +); +``` +### Engine Events + +The `DefaultSubscriptionEngine` dispatches events during processing on the passed event dispatcher. +You can register your own listeners to hook into the engine, for example for logging, metrics or batching. + +| Event | Description | +|--------------------------|----------------------------------------------------------------------| +| `OnCommand` | A command was passed to the engine for execution | +| `OnSubscriptions` | The engine determined the subscriptions for the current command | +| `OnHandleMessage` | A message is about to be passed to a subscriber | +| `OnHandleMessageSuccess` | A message was successfully handled by a subscriber | +| `OnHandleMessageError` | An error occurred while a subscriber was handling a message | +| `OnProcessingFinished` | The engine finished processing the stream (ended or limit reached) | +| `OnResult` | The engine finished the command and returns the result | + +```php +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; +use Symfony\Component\EventDispatcher\EventDispatcher; + +$eventDispatcher = new EventDispatcher(); +$eventDispatcher->addListener( + OnHandleMessageError::class, + static function (OnHandleMessageError $event): void { + // own error handling like logging or metrics + }, ); ``` ### Catch up Subscription Engine @@ -1249,15 +1279,20 @@ Especially in combination with the `CatchUpSubscriptionEngine` and `ThrowOnError ## Usage -The Subscription Engine has a few methods needed to use it effectively. -A `SubscriptionEngineCriteria` can be passed to all of these methods to filter the respective subscriptions. +The Subscription Engine is controlled with command objects. +Each command is passed to the `execute` method, which returns a `Result` with the errors that occurred. +Every command accepts `ids` and `groups` parameters to filter the subscriptions the command should be applied to. ```php -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -$criteria = new SubscriptionEngineCriteria( - ids: ['profile_1', 'welcome_email'], - groups: ['default'], +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->execute( + new Run( + ids: ['profile_1', 'welcome_email'], + groups: ['default'], + ), ); ``` @@ -1272,52 +1307,62 @@ In this step, the subscription engine also tries to call the `setup` method if a After the setup process, the subscription is set to booting or active. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->setup(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Setup()); ``` :::tip -You can skip the booting step with the second boolean parameter named `skipBooting`. +You can skip the booting step with the `skipBooting` parameter: `new Setup(skipBooting: true)`. ::: ### Boot -You can boot the subscriptions with the `boot` method. +You can boot the subscriptions with the `Boot` command. All booting subscriptions will catch up to the current event stream. After the boot process, the subscription is set to active or finished. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->boot(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Boot()); ``` + +:::tip +You can limit the number of processed messages with the `limit` parameter: `new Boot(limit: 100)`. +::: + ### Run All active subscriptions are continued and updated here. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->run(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Run()); ``` + +:::tip +You can limit the number of processed messages with the `limit` parameter: `new Run(limit: 100)`. +::: + ### Teardown If subscriptions are detached, they can be cleaned up here. The subscription engine also tries to call the `teardown` method if available. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->teardown(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Teardown()); ``` ### Remove @@ -1326,11 +1371,11 @@ An attempt is made to call the `teardown` method if available. But the entry will still be removed if it doesn't work. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->remove(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Remove()); ``` ### Reactivate @@ -1338,11 +1383,11 @@ If a subscription had an error or is outdated, you can reactivate it. As a result, the subscription gets in the last status again. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->reactivate(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Reactivate()); ``` ### Pause @@ -1351,38 +1396,39 @@ The subscription will then no longer be managed by the subscription engine. You can reactivate the subscription if you want so that it continues. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->pause(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Pause()); ``` -### Status +### Refresh -To get the current status of all subscriptions, you can get them using the `subscriptions` method. +If you change the metadata of a subscriber in the code (e.g. `runMode`, `group` or `cleanupTasks`), +you can use the `Refresh` command to update the existing subscriptions in the store. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptions = $subscriptionEngine->subscriptions(new SubscriptionEngineCriteria()); - -foreach ($subscriptions as $subscription) { - echo $subscription->status()->value; -} +$subscriptionEngine->execute(new Refresh()); ``` -### Refresh +### Status -If you change the metadata of a subscriber in the code (e.g. `runMode`, `group` or `cleanupTasks`), -you can use the `refresh` method to update the existing subscriptions in the store. +To get the current status of all subscriptions, you can get them using the `subscriptions` method. +A `SubscriptionEngineCriteria` can be passed to filter the subscriptions. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->refresh(new SubscriptionEngineCriteria()); +$subscriptions = $subscriptionEngine->subscriptions(new SubscriptionEngineCriteria()); + +foreach ($subscriptions as $subscription) { + echo $subscription->status()->value; +} ``` ## Learn more diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a4a436217..8db04aa22 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -102,6 +102,12 @@ parameters: count: 1 path: src/Store/TaggableDoctrineDbalStore.php + - + message: '#^Parameter \#1 \$command of callable Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\BootHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\PauseHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\ReactivateHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RefreshHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RemoveHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RunHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\SetupHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\TeardownHandler expects Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Boot\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Pause\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Reactivate\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Refresh\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Remove\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Run\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Setup\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Teardown, Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Command given\.$#' + identifier: argument.type + count: 1 + path: src/Subscription/Engine/DefaultSubscriptionEngine.php + - message: '#^Parameter \#1 \$eventClass of method Patchlevel\\EventSourcing\\Metadata\\Event\\EventRegistry\:\:eventName\(\) expects class\-string, string given\.$#' identifier: argument.type @@ -396,18 +402,6 @@ parameters: count: 2 path: tests/Unit/QueryBus/ServiceHandlerProviderTest.php - - - message: '#^Match expression does not handle remaining value\: string$#' - identifier: match.unhandled - count: 1 - path: tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php - - - - message: '#^Parameter \#1 \$error of static method Patchlevel\\EventSourcing\\Subscription\\ThrowableToErrorContextTransformer\:\:transform\(\) expects Throwable, Throwable\|null given\.$#' - identifier: argument.type - count: 8 - path: tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php - - message: '#^Offset ''args'' on array\{file\: literal\-string&non\-falsy\-string, line\: int, function\: ''createException'', class\: ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Unit\\\\Subscription\\\\ErrorContextTest'', type\: ''\-\>'', args\: array\\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset diff --git a/src/Console/Command/SubscriptionBootCommand.php b/src/Console/Command/SubscriptionBootCommand.php index 9e5cb989f..c91d8c309 100644 --- a/src/Console/Command/SubscriptionBootCommand.php +++ b/src/Console/Command/SubscriptionBootCommand.php @@ -5,7 +5,11 @@ namespace Patchlevel\EventSourcing\Console\Command; use Closure; +use LogicException; use Patchlevel\EventSourcing\Console\InputHelper; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; +use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\Worker\DefaultWorker; use Symfony\Component\Console\Attribute\AsCommand; @@ -86,7 +90,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $criteria = $this->resolveCriteriaIntoCriteriaWithOnlyIds($criteria); if ($setup) { - $this->engine->setup($criteria); + $this->engine->execute(new Setup( + $criteria->ids, + $criteria->groups, + )); } $logger = new ConsoleLogger($output); @@ -94,7 +101,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $worker = DefaultWorker::create( function (Closure $stop) use ($criteria, $messageLimit, &$finished): void { - $result = $this->engine->boot($criteria, $messageLimit); + $result = $this->engine->execute(new Boot( + $criteria->ids, + $criteria->groups, + $messageLimit, + )); + + if (!$result instanceof ProcessedResult) { + throw new LogicException('Expected ProcessedResult'); + } if (!$result->finished) { return; diff --git a/src/Console/Command/SubscriptionPauseCommand.php b/src/Console/Command/SubscriptionPauseCommand.php index 50aa8a8f8..1deebcdde 100644 --- a/src/Console/Command/SubscriptionPauseCommand.php +++ b/src/Console/Command/SubscriptionPauseCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,7 +18,10 @@ final class SubscriptionPauseCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->pause($criteria); + $this->engine->execute(new Pause( + $criteria->ids, + $criteria->groups, + )); return 0; } diff --git a/src/Console/Command/SubscriptionReactivateCommand.php b/src/Console/Command/SubscriptionReactivateCommand.php index 81ec2e1f0..dd6b01bc9 100644 --- a/src/Console/Command/SubscriptionReactivateCommand.php +++ b/src/Console/Command/SubscriptionReactivateCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,7 +18,10 @@ final class SubscriptionReactivateCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->reactivate($criteria); + $this->engine->execute(new Reactivate( + $criteria->ids, + $criteria->groups, + )); return 0; } diff --git a/src/Console/Command/SubscriptionRefreshCommand.php b/src/Console/Command/SubscriptionRefreshCommand.php index fa6b0cdab..6af998722 100644 --- a/src/Console/Command/SubscriptionRefreshCommand.php +++ b/src/Console/Command/SubscriptionRefreshCommand.php @@ -4,14 +4,11 @@ namespace Patchlevel\EventSourcing\Console\Command; -use LogicException; -use Patchlevel\EventSourcing\Subscription\Engine\CanRefreshSubscriptions; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function sprintf; - #[AsCommand( 'event-sourcing:subscription:refresh', 'Refresh subscriptions (run-mode, group)', @@ -20,16 +17,8 @@ final class SubscriptionRefreshCommand extends SubscriptionCommand { protected function execute(InputInterface $input, OutputInterface $output): int { - if (!$this->engine instanceof CanRefreshSubscriptions) { - throw new LogicException(sprintf( - '"%s" does not implement "%s" and cannot call refresh.', - $this->engine::class, - CanRefreshSubscriptions::class, - )); - } - $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->refresh($criteria); + $this->engine->execute(new Refresh($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Console/Command/SubscriptionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php index dbff5177b..5b8c345b5 100644 --- a/src/Console/Command/SubscriptionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\OutputStyle; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -27,7 +28,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $this->engine->remove($criteria); + $this->engine->execute(new Remove($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Console/Command/SubscriptionRunCommand.php b/src/Console/Command/SubscriptionRunCommand.php index 142f274b4..64da55092 100644 --- a/src/Console/Command/SubscriptionRunCommand.php +++ b/src/Console/Command/SubscriptionRunCommand.php @@ -7,6 +7,9 @@ use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\SubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\Worker\DefaultWorker; use Symfony\Component\Console\Attribute\AsCommand; @@ -95,7 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $worker = DefaultWorker::create( function () use ($criteria, $messageLimit, $sleep): void { - $this->engine->run($criteria, $messageLimit); + $this->engine->execute(new Run($criteria->ids, $criteria->groups, $messageLimit)); if (!$this->store instanceof SubscriptionStore) { return; @@ -113,8 +116,8 @@ function () use ($criteria, $messageLimit, $sleep): void { ); if ($rebuild) { - $this->engine->remove($criteria); - $this->engine->boot($criteria); + $this->engine->execute(new Remove($criteria->ids, $criteria->groups)); + $this->engine->execute(new Boot($criteria->ids, $criteria->groups)); } $supportSubscription = $this->store instanceof SubscriptionStore && $this->store->supportSubscription(); diff --git a/src/Console/Command/SubscriptionSetupCommand.php b/src/Console/Command/SubscriptionSetupCommand.php index 4eb916cb9..c537ea3ff 100644 --- a/src/Console/Command/SubscriptionSetupCommand.php +++ b/src/Console/Command/SubscriptionSetupCommand.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\InputHelper; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -34,7 +35,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $skipBooting = InputHelper::bool($input->getOption('skip-booting')); $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->setup($criteria, $skipBooting); + $this->engine->execute(new Setup($criteria->ids, $criteria->groups, $skipBooting)); return 0; } diff --git a/src/Console/Command/SubscriptionTeardownCommand.php b/src/Console/Command/SubscriptionTeardownCommand.php index 448429a0b..fa4228667 100644 --- a/src/Console/Command/SubscriptionTeardownCommand.php +++ b/src/Console/Command/SubscriptionTeardownCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,7 +18,7 @@ final class SubscriptionTeardownCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->teardown($criteria); + $this->engine->execute(new Teardown($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Subscription/Engine/CanRefreshSubscriptions.php b/src/Subscription/Engine/CanRefreshSubscriptions.php deleted file mode 100644 index 895a1d7d6..000000000 --- a/src/Subscription/Engine/CanRefreshSubscriptions.php +++ /dev/null @@ -1,10 +0,0 @@ -parent->setup($criteria, $skipBooting); - } - - public function boot(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - $results = []; + $mergedResult = new ProcessedResult(0); $catchupLimit = $this->limit ?? PHP_INT_MAX; for ($i = 0; $i < $catchupLimit; $i++) { - $lastResult = $this->parent->boot($criteria, $limit); + $result = $this->parent->execute($command); - $results[] = $lastResult; - - if ($lastResult->processedMessages === 0) { - break; + if (!$result instanceof ProcessedResult) { + return $result; } - } - - return $this->mergeResult(...$results); - } - - public function run(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - $mergedResult = new ProcessedResult(0); - $catchupLimit = $this->limit ?? PHP_INT_MAX; - - for ($i = 0; $i < $catchupLimit; $i++) { - $result = $this->parent->run($criteria, $limit); $mergedResult = $this->mergeResult($mergedResult, $result); if ($result->processedMessages === 0) { @@ -62,45 +42,12 @@ public function run(SubscriptionEngineCriteria|null $criteria = null, int|null $ return $mergedResult; } - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->teardown($criteria); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->remove($criteria); - } - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->reactivate($criteria); - } - - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->pause($criteria); - } - /** @return list */ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array { return $this->parent->subscriptions($criteria); } - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result - { - if (!$this->parent instanceof CanRefreshSubscriptions) { - throw new LogicException(sprintf( - '"%s" does not implement "%s" and cannot call refresh.', - $this->parent::class, - CanRefreshSubscriptions::class, - )); - } - - return $this->parent->refresh($criteria); - } - private function mergeResult(ProcessedResult ...$results): ProcessedResult { $processedMessages = 0; diff --git a/src/Subscription/Engine/CleanupRunner.php b/src/Subscription/Engine/CleanupRunner.php new file mode 100644 index 000000000..d32e99224 --- /dev/null +++ b/src/Subscription/Engine/CleanupRunner.php @@ -0,0 +1,73 @@ +cleaner) { + throw new CleanerNotConfigured(); + } + + try { + $this->cleaner->cleanup($subscription); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: For Subscription "%s" the cleanup tasks have been executed.', + $subscription->id(), + ), + ); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscription "%s" has an error in the cleanup tasks: %s', + $subscription->id(), + $e->getMessage(), + ), + ); + + if ($force) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + )); + } + + return new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + $this->subscriptionManager->remove($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + )); + + return null; + } +} diff --git a/src/Subscription/Engine/Command/Boot.php b/src/Subscription/Engine/Command/Boot.php new file mode 100644 index 000000000..acf6be1cf --- /dev/null +++ b/src/Subscription/Engine/Command/Boot.php @@ -0,0 +1,21 @@ +|null $ids + * @param list|null $groups + * @param positive-int|null $limit + */ + public function __construct( + array|null $ids = null, + array|null $groups = null, + public readonly int|null $limit = null, + ) { + parent::__construct($ids, $groups); + } +} diff --git a/src/Subscription/Engine/Command/Command.php b/src/Subscription/Engine/Command/Command.php new file mode 100644 index 000000000..032742561 --- /dev/null +++ b/src/Subscription/Engine/Command/Command.php @@ -0,0 +1,18 @@ +|null $ids + * @param list|null $groups + */ + public function __construct( + public readonly array|null $ids = null, + public readonly array|null $groups = null, + ) { + } +} diff --git a/src/Subscription/Engine/Command/Pause.php b/src/Subscription/Engine/Command/Pause.php new file mode 100644 index 000000000..28780d532 --- /dev/null +++ b/src/Subscription/Engine/Command/Pause.php @@ -0,0 +1,9 @@ +|null $ids + * @param list|null $groups + * @param positive-int|null $limit + */ + public function __construct( + array|null $ids = null, + array|null $groups = null, + public readonly int|null $limit = null, + ) { + parent::__construct($ids, $groups); + } +} diff --git a/src/Subscription/Engine/Command/Setup.php b/src/Subscription/Engine/Command/Setup.php new file mode 100644 index 000000000..83ef8617f --- /dev/null +++ b/src/Subscription/Engine/Command/Setup.php @@ -0,0 +1,20 @@ +|null $ids + * @param list|null $groups + */ + public function __construct( + array|null $ids = null, + array|null $groups = null, + public readonly bool $skipBooting = false, + ) { + parent::__construct($ids, $groups); + } +} diff --git a/src/Subscription/Engine/Command/Teardown.php b/src/Subscription/Engine/Command/Teardown.php new file mode 100644 index 000000000..e144b032c --- /dev/null +++ b/src/Subscription/Engine/Command/Teardown.php @@ -0,0 +1,9 @@ + */ - private array $batching = []; - private readonly RetryStrategyRepository $retryStrategyRepository; + /** @var array, Handler> */ + private readonly array $handlers; + public function __construct( private readonly MessageLoader $messageLoader, SubscriptionStore $subscriptionStore, @@ -44,6 +61,7 @@ public function __construct( RetryStrategyRepository|null $retryStrategyRepository = null, private readonly LoggerInterface|null $logger = null, private readonly Cleaner|null $cleaner = null, + private readonly EventDispatcherInterface $eventDispatcher = new EventDispatcher(), ) { $this->subscriptionManager = new SubscriptionManager($subscriptionStore); @@ -55,1311 +73,171 @@ public function __construct( 'no_retry' => new NoRetryStrategy(), ]); } - } - - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - $this->logger?->info( - 'Subscription Engine: Start to setup.', + $cleanupRunner = new CleanupRunner( + $this->subscriptionManager, + $this->cleaner, + $this->logger, ); - $this->discoverNewSubscriptions(); - $this->retrySubscriptions($criteria, Status::New); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::New], - ), - function (SubscriptionCollection $subscriptions) use ($skipBooting): Result { - if (count($subscriptions) === 0) { - $this->logger?->info('Subscription Engine: No subscriptions to setup, finish setup.'); - - return new Result(); - } - - /** @var list $errors */ - $errors = []; - - $latestIndex = $this->messageLoader->lastIndex(); - - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - throw SubscriberNotFound::forSubscriptionId($subscription->id()); - } - - $setupMethod = $subscriber->setupMethod(); - - if (!$setupMethod) { - if ($subscription->runMode() === RunMode::FromNow) { - $subscription->changePosition($latestIndex); - $subscription->active(); - } else { - $skipBooting ? $subscription->active() : $subscription->booting(); - } - - $this->subscriptionManager->update($subscription); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has no setup method, set to %s.', - $subscriber::class, - $subscription->id(), - $subscription->runMode() === RunMode::FromNow || $skipBooting ? 'active' : 'booting', - )); - - continue; - } - - try { - $setupMethod(); - - if ($subscription->runMode() === RunMode::FromNow) { - $subscription->changePosition($latestIndex); - $subscription->active(); - } else { - $skipBooting ? $subscription->active() : $subscription->booting(); - } - - $this->subscriptionManager->update($subscription); - - $this->logger?->debug(sprintf( - 'Subscription Engine: For Subscriber "%s" for "%s" the setup method has been executed, set to %s.', - $subscriber::class, - $subscription->id(), - $subscription->runMode() === RunMode::FromNow || $skipBooting ? 'active' : 'booting', - )); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has an error in the setup method: %s', - $subscriber::class, - $subscription->id(), - $e->getMessage(), - )); - - $this->handleError($subscription, $e); - - $errors[] = new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - } - - return new Result($errors); - }, + $messageProcessor = new MessageProcessor( + $this->subscriberRepository, + $this->eventDispatcher, + $this->logger, ); - } - - public function boot( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult { - if ($this->processing) { - throw new AlreadyProcessing(); - } - - $this->processing = true; - $this->batching = []; - - try { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->logger?->info( - 'Subscription Engine: Start booting.', - ); - - $this->discoverNewSubscriptions(); - $this->retrySubscriptions($criteria, Status::Booting); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Booting], - ), - function (SubscriptionCollection $subscriptions) use ($limit): ProcessedResult { - if (count($subscriptions) === 0) { - $this->logger?->info('Subscription Engine: No subscriptions in booting status, finish booting.'); - - return new ProcessedResult(0, true); - } - - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if ($subscriber) { - continue; - } - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found, skipped.', - $subscription->id(), - ), - ); - - $subscriptions->remove($subscription); - } - - $startIndex = $subscriptions->lowestPosition(); - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Event stream is processed for booting from position %s.', - $startIndex, - ), - ); - - /** @var list $errors */ - $errors = []; - $stream = null; - $messageCounter = 0; - $lastIndex = null; - - try { - $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); - - foreach ($stream as $index => $message) { - $messageCounter++; - $lastIndex = $index; - - foreach ($subscriptions as $subscription) { - if ($subscription->position() >= $index) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue booting.', - $subscription->id(), - $subscription->position(), - $index, - ), - ); - - continue; - } - - $error = $this->handleMessage($index, $message, $subscription); - - if (!$error) { - continue; - } - - $errors[] = $error; - - $subscriptions->remove($subscription); - - if (count($subscriptions) === 0) { - $this->logger?->info( - 'Subscription Engine: No subscriptions in booting status, finish booting.', - ); - - break 2; - } - } - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Current event stream position for booting: %s', - $index, - ), - ); - - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info( - sprintf( - 'Subscription Engine: Message limit (%d) reached, finish booting.', - $limit, - ), - ); - - return new ProcessedResult( - $messageCounter, - false, - $errors, - ); - } - } - } finally { - $stream?->close(); - - if ($lastIndex !== null && $messageCounter > 0) { - foreach ($subscriptions as $subscription) { - $error = $this->ensureCommitBatch($subscription, $lastIndex); - - if ($error) { - $errors[] = $error; - - $subscriptions->remove($subscription); - } - - $this->subscriptionManager->update($subscription); - } - } - } - - $this->logger?->debug('Subscription Engine: End of stream for booting has been reached.'); - - foreach ($subscriptions as $subscription) { - if ($subscription->runMode() === RunMode::Once) { - $subscription->finished(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', - $subscription->id(), - )); - - continue; - } - - $subscription->active(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" has been set to active after booting.', - $subscription->id(), - )); - } - - $this->logger?->info('Subscription Engine: Finish booting.'); - - return new ProcessedResult( - $messageCounter, - true, - $errors, - ); - }, - ); - } finally { - $this->processing = false; - } - } - - public function run( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult { - if ($this->processing) { - throw new AlreadyProcessing(); - } - - $this->processing = true; - $this->batching = []; - - try { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->logger?->info('Subscription Engine: Start processing.'); - - $this->discoverNewSubscriptions(); - $this->markDetachedSubscriptions($criteria); - $this->retrySubscriptions($criteria, Status::Active); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Active], - ), - function (SubscriptionCollection $subscriptions) use ($limit): ProcessedResult { - if (count($subscriptions) === 0) { - $this->logger?->info('Subscription Engine: No subscriptions to process, finish processing.'); - - return new ProcessedResult(0, true); - } - - $startIndex = $subscriptions->lowestPosition(); - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Event stream is processed from position %d.', - $startIndex, - ), - ); - - /** @var list $errors */ - $errors = []; - $stream = null; - $messageCounter = 0; - $lastIndex = null; - - try { - $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); - - foreach ($stream as $index => $message) { - $messageCounter++; - $lastIndex = $index; - - foreach ($subscriptions as $subscription) { - if ($subscription->position() >= $index) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue processing.', - $subscription->id(), - $subscription->position(), - $index, - ), - ); - - continue; - } - - $error = $this->handleMessage($index, $message, $subscription); - - if (!$error) { - continue; - } - - $errors[] = $error; - - $subscriptions->remove($subscription); - - if (count($subscriptions) === 0) { - $this->logger?->info( - 'Subscription Engine: No subscriptions in booting status, finish booting.', - ); - - break 2; - } - } - - $this->logger?->debug(sprintf( - 'Subscription Engine: Current event stream position: %s', - $index, - )); - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info( - sprintf( - 'Subscription Engine: Message limit (%d) reached, finish processing.', - $limit, - ), - ); - - return new ProcessedResult($messageCounter, false, $errors); - } - } - } finally { - $stream?->close(); - - if ($lastIndex !== null && $messageCounter > 0) { - foreach ($subscriptions as $subscription) { - $error = $this->ensureCommitBatch($subscription, $lastIndex); - - if ($error) { - $errors[] = $error; - - $subscriptions->remove($subscription); - } - - $this->subscriptionManager->update($subscription); - } - } - } - - foreach ($subscriptions as $subscription) { - if ($subscription->runMode() !== RunMode::Once) { - continue; - } - - $subscription->finished(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', - $subscription->id(), - )); - } - - $this->logger?->info( - sprintf( - 'Subscription Engine: End of stream on position "%d" has been reached, finish processing.', - $lastIndex, - ), - ); - - return new ProcessedResult($messageCounter, true, $errors); - }, - ); - } finally { - $this->processing = false; - } - } - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - $this->logger?->info('Subscription Engine: Start teardown detached subscriptions.'); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Detached], + $this->handlers = [ + Boot::class => new BootHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $messageProcessor, + $this->eventDispatcher, + $this->logger, + ), + Pause::class => new PauseHandler( + $this->subscriptionManager, + $this->logger, + ), + Reactivate::class => new ReactivateHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Refresh::class => new RefreshHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Remove::class => new RemoveHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $cleanupRunner, + $this->logger, + ), + Run::class => new RunHandler( + $this->messageLoader, + $this->subscriptionManager, + $messageProcessor, + $this->eventDispatcher, + $this->logger, + ), + Setup::class => new SetupHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $this->retryStrategyRepository, + $this->logger, + ), + Teardown::class => new TeardownHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $cleanupRunner, + $this->logger, + ), + ]; + + $this->eventDispatcher->addSubscriber( + new DiscoverSubscriber( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - /** @var list $errors */ - $errors = []; - - foreach ($subscriptions as $subscription) { - if ($subscription->hasCleanupTasks()) { - $error = $this->cleanup($subscription); - - if ($error) { - $errors[] = $error; - } - - continue; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $this->logger?->warning( - sprintf( - 'Subscription Engine: Subscriber for "%s" to teardown or cleanup not found, skipped.', - $subscription->id(), - ), - ); - - continue; - } - - $teardownMethod = $subscriber->teardownMethod(); - - if (!$teardownMethod) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has no teardown method and was immediately removed.', - $subscriber::class, - $subscription->id(), - ), - ); - - continue; - } - - try { - $teardownMethod(); - - $this->logger?->debug(sprintf( - 'Subscription Engine: For Subscriber "%s" for "%s" the teardown method has been executed and is now prepared to be removed.', - $subscriber::class, - $subscription->id(), - )); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscription "%s" for "%s" has an error in the teardown method, skipped: %s', - $subscriber::class, - $subscription->id(), - $e->getMessage(), - ), - ); - - $errors[] = new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - - continue; - } - - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - ), - ); - } - - $this->logger?->info('Subscription Engine: Finish teardown.'); - - return new Result($errors); - }, ); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, + $this->eventDispatcher->addSubscriber( + new RetrySubscriber( + $this->subscriptionManager, + $this->subscriberRepository, + $this->retryStrategyRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - /** @var list $errors */ - $errors = []; - - foreach ($subscriptions as $subscription) { - if ($subscription->isNew()) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - ), - ); - - continue; - } - - if ($subscription->hasCleanupTasks()) { - $error = $this->cleanup($subscription, true); - - if ($error) { - $errors[] = $error; - } - - continue; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" removed without a suitable subscriber.', - $subscription->id(), - ), - ); - - continue; - } - - $teardownMethod = $subscriber->teardownMethod(); - - if (!$teardownMethod) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), - ); - - continue; - } - - try { - $teardownMethod(); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscriber "%s" teardown method could not be executed: %s', - $subscriber::class, - $e->getMessage(), - ), - ); - - $errors[] = new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), - ); - } - - return new Result($errors); - }, ); - } - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ - Status::Error, - Status::Failed, - Status::Detached, - Status::Paused, - Status::Finished, - ], + $this->eventDispatcher->addSubscriber( + new BatchSubscriber( + $this->subscriberRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found, skipped.', - $subscription->id(), - ), - ); - - continue; - } - - $error = $subscription->subscriptionError(); - - if ($error) { - $subscription->doRetry(); - $subscription->resetRetry(); - - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', - $subscriber::class, - $subscription->id(), - )); - - continue; - } - - $subscription->active(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', - $subscriber::class, - $subscription->id(), - )); - } - - return new Result(); - }, ); - } - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ - Status::Active, - Status::Booting, - Status::Error, - ], + $this->eventDispatcher->addSubscriber( + new FailSubscriber( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - /** @var Subscription $subscription */ - foreach ($subscriptions as $subscription) { - $subscription->pause(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" is paused.', - $subscription->id(), - )); - } - - return new Result(); - }, ); - } - /** @return list */ - public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->find( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, + $this->eventDispatcher->addListener( + OnCommand::class, + new DetachListener( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, ), + 32, ); } - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result + public function execute(Command $command): Result { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - )); - - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - continue; - } - - $changed = false; - - if ($subscription->runMode() !== $subscriber->metadata()->runMode) { - $changed = true; - $oldRunMode = $subscription->runMode(); - $subscription->changeRunMode($subscriber->metadata()->runMode); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" run mode changed from "%s" to "%s".', - $subscription->id(), - $oldRunMode->value, - $subscription->runMode()->value, - ), - ); - } - - if ($subscription->group() !== $subscriber->metadata()->group) { - $changed = true; - $oldGroup = $subscription->group(); - $subscription->changeGroup($subscriber->metadata()->group); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" group changed from "%s" to "%s".', - $subscription->id(), - $oldGroup, - $subscription->group(), - ), - ); - } - - $cleanupTasks = $this->cleanupTasks($subscriber); - - if ($subscription->cleanupTasks() !== $cleanupTasks) { - $changed = true; - $subscription->replaceCleanupTasks($cleanupTasks); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" cleanup tasks changed.', - $subscription->id(), - ), - ); - } + $this->logger?->info( + 'Subscription Engine: ' . $command::class . ' command received.', + ); - if (!$changed) { - continue; - } + if ($this->processing) { + $this->logger?->error( + 'Subscription Engine: Already processing, skip.', + ); - $this->subscriptionManager->update($subscription); + throw new AlreadyProcessing(); } - $this->subscriptionManager->flush(); - - return new Result(); - } - - private function handleMessage(int $index, Message $message, Subscription $subscription): Error|null - { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - throw SubscriberNotFound::forSubscriptionId($subscription->id()); - } + $this->processing = true; - $subscribeMethods = $subscriber->subscribeMethods($message->event()::class); + try { + $handler = $this->handlers[$command::class] ?? null; - if ($subscribeMethods === []) { - if (!isset($this->batching[$subscription->id()])) { - $subscription->changePosition($index); + if ($handler === null) { + throw new InvalidArgumentException('No handler found for command: ' . $command::class); } $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has no subscribe methods for "%s", continue.', - $subscriber::class, - $subscription->id(), - $message->event()::class, - ), + 'Subscription Engine: ' . $command::class . ' command handled by ' . $handler::class, ); - return null; - } - - $error = $this->checkAndBeginBatch($subscription); + $event = new OnCommand($command); + $this->eventDispatcher->dispatch($event); - if ($error) { - return $error; - } + $result = $handler($command); - try { - foreach ($subscribeMethods as $subscribeMethod) { - $subscribeMethod($message); - } - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', - $subscriber::class, - $subscription->id(), - $message->event()::class, - $e->getMessage(), - ), - ); + $event = new OnResult($command, $result); + $this->eventDispatcher->dispatch($event); - $this->handleError($subscription, $e, $message, $index); - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, + return $result; + } finally { + $this->logger?->info( + 'Subscription Engine: ' . $command::class . ' command processed.', ); - } - - if ($this->shouldCommitBatch($subscription)) { - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" forces to commit batch.', - $subscription->id(), - )); - $this->ensureCommitBatch($subscription, $index); - } - - if (!isset($this->batching[$subscription->id()])) { - $subscription->changePosition($index); + $this->processing = false; } - - $subscription->resetRetry(); - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', - $subscriber::class, - $subscription->id(), - $message->event()::class, - ), - ); - - return null; } - private function subscriber(string $subscriberId): MetadataSubscriberAccessor|null - { - return $this->subscriberRepository->get($subscriberId); - } - - private function markDetachedSubscriptions(SubscriptionEngineCriteria $criteria): void + /** @return list */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array { - $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Active, Status::Paused, Status::Finished], - ), - function (SubscriptionCollection $subscriptions): void { - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if ($subscriber) { - continue; - } - - $subscription->detached(); - $this->subscriptionManager->update($subscription); + $criteria ??= new SubscriptionEngineCriteria(); - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found and has been marked as detached.', - $subscription->id(), - ), - ); - } - }, - ); - } + $this->eventDispatcher->dispatch(new OnSubscriptions($criteria)); - private function retrySubscriptions(SubscriptionEngineCriteria $criteria, Status $previousStatus): void - { - $this->subscriptionManager->findForUpdate( + return $this->subscriptionManager->find( new SubscriptionCriteria( ids: $criteria->ids, groups: $criteria->groups, - status: [Status::Error], ), - function (SubscriptionCollection $subscriptions) use ($previousStatus): void { - /** @var Subscription $subscription */ - foreach ($subscriptions as $subscription) { - $error = $subscription->subscriptionError(); - - if ($error === null) { - continue; - } - - if ($error->previousStatus !== $previousStatus) { - continue; - } - - if (!$this->retryStrategy($subscription)->shouldRetry($subscription)) { - continue; - } - - $subscription->doRetry(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Retry subscription "%s" (%d) and set back to %s.', - $subscription->id(), - $subscription->retryAttempt(), - $subscription->status()->value, - ), - ); - } - }, ); } - - private function discoverNewSubscriptions(): void - { - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria()); - - $latestIndex = null; - - foreach ($this->subscriberRepository->all() as $subscriber) { - foreach ($subscriptions as $subscription) { - if ($subscription->id() === $subscriber->metadata()->id) { - continue 2; - } - } - - $subscription = new Subscription( - $subscriber->metadata()->id, - $subscriber->metadata()->group, - $subscriber->metadata()->runMode, - cleanupTasks: $this->cleanupTasks($subscriber), - ); - - if ($subscriber->setupMethod() === null && $subscriber->metadata()->runMode === RunMode::FromNow) { - if ($latestIndex === null) { - $latestIndex = $this->messageLoader->lastIndex(); - } - - $subscription->changePosition($latestIndex); - $subscription->active(); - } - - $this->subscriptionManager->add($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: New Subscriber "%s" was found and added to the subscription store.', - $subscriber->metadata()->id, - ), - ); - } - - $this->subscriptionManager->flush(); - } - - private function handleError( - Subscription $subscription, - Throwable $throwable, - Message|null $message = null, - int|null $index = null, - ): void { - if ($this->needRollback($subscription)) { - $this->rollback($subscription); - } - - $retryStrategy = $this->retryStrategy($subscription); - - if (!$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($subscription)) { - $subscription->error($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $this->handleFailed($subscription, $throwable, $message, $index); - } - - private function handleFailed( - Subscription $subscription, - Throwable $throwable, - Message|null $message = null, - int|null $index = null, - ): void { - if (!$message || $index === null) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - if ($subscriber->subscriber() instanceof BatchableSubscriber) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $failedMethod = $subscriber->failedMethod(); - - if (!$failedMethod) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - try { - $failedMethod($message, $throwable); - $subscription->changePosition($index); - $subscription->resetRetry(); - - $this->subscriptionManager->update($subscription); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the failed method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - } - } - - private function needRollback(Subscription $subscription): bool - { - return isset($this->batching[$subscription->id()]); - } - - private function rollback(Subscription $subscription): void - { - if (!isset($this->batching[$subscription->id()])) { - throw new UnexpectedError('No batch to rollback.'); - } - - $subscriber = $this->batching[$subscription->id()]; - - unset($this->batching[$subscription->id()]); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" rollback the batch.', - $subscription->id(), - )); - - try { - $subscriber->rollbackBatch(); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the rollback batch method: %s', - $subscription->id(), - $e->getMessage(), - )); - } - } - - private function ensureCommitBatch(Subscription $subscription, int $index): Error|null - { - if (!isset($this->batching[$subscription->id()])) { - return null; - } - - $subscriber = $this->batching[$subscription->id()]; - - unset($this->batching[$subscription->id()]); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" commits the batch.', - $subscription->id(), - )); - - try { - $subscriber->commitBatch(); - $subscription->changePosition($index); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the commit batch method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $this->handleError($subscription, $e); - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - return null; - } - - private function checkAndBeginBatch(Subscription $subscription): Error|null - { - if (isset($this->batching[$subscription->id()])) { - return null; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - return null; - } - - $realSubscriber = $subscriber->subscriber(); - - if (!$realSubscriber instanceof BatchableSubscriber) { - return null; - } - - $this->batching[$subscription->id()] = $realSubscriber; - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" starts a new batch.', - $subscription->id(), - )); - - try { - $realSubscriber->beginBatch(); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the begin batch method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $this->handleError($subscription, $e); - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - return null; - } - - private function shouldCommitBatch(Subscription $subscription): bool - { - if (!isset($this->batching[$subscription->id()])) { - return false; - } - - return $this->batching[$subscription->id()]->forceCommit(); - } - - private function retryStrategy(Subscription $subscription): RetryStrategy - { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber instanceof MetadataSubscriberAccessor) { - return $this->retryStrategyRepository->getDefaultRetryStrategy(); - } - - $retryStrategy = $subscriber->metadata()->retryStrategy; - - if ($retryStrategy === null) { - return $this->retryStrategyRepository->getDefaultRetryStrategy(); - } - - return $this->retryStrategyRepository->get($retryStrategy); - } - - private function cleanup(Subscription $subscription, bool $force = false): Error|null - { - if (!$this->cleaner) { - throw new CleanerNotConfigured(); - } - - try { - $this->cleaner->cleanup($subscription); - $this->logger?->debug( - sprintf( - 'Subscription Engine: For Subscription "%s" the cleanup tasks have been executed.', - $subscription->id(), - ), - ); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscription "%s" has an error in the cleanup tasks: %s', - $subscription->id(), - $e->getMessage(), - ), - ); - - if ($force) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - )); - } - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - $this->subscriptionManager->remove($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - )); - - return null; - } - - /** @return list|null */ - private function cleanupTasks(MetadataSubscriberAccessor $subscriber): array|null - { - $method = $subscriber->cleanupMethod(); - - if (!$method) { - return null; - } - - if (!$this->cleaner) { - throw new CleanerNotConfigured(); - } - - return array_values([...$method()]); - } } diff --git a/src/Subscription/Engine/Event/OnCommand.php b/src/Subscription/Engine/Event/OnCommand.php new file mode 100644 index 000000000..2addb216b --- /dev/null +++ b/src/Subscription/Engine/Event/OnCommand.php @@ -0,0 +1,15 @@ + */ + public array $errors = []; + + /** @param self::REASON_* $reason */ + public function __construct( + public readonly Command $command, + public readonly string $reason, + public readonly int $processed, + public readonly int|null $lastIndex = null, + ) { + } +} diff --git a/src/Subscription/Engine/Event/OnResult.php b/src/Subscription/Engine/Event/OnResult.php new file mode 100644 index 000000000..ff3421f05 --- /dev/null +++ b/src/Subscription/Engine/Event/OnResult.php @@ -0,0 +1,17 @@ + + */ +final class BootHandler implements Handler +{ + public function __construct( + private readonly MessageLoader $messageLoader, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly MessageProcessor $messageProcessor, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): ProcessedResult + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Booting], + ), + function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions in booting status, finish booting.'); + + return new ProcessedResult(0, true); + } + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if ($subscriber) { + continue; + } + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found, skipped.', + $subscription->id(), + ), + ); + + $subscriptions->remove($subscription); + } + + $startIndex = $subscriptions->lowestPosition(); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Event stream is processed for booting from position %s.', + $startIndex, + ), + ); + + /** @var list $errors */ + $errors = []; + $stream = null; + $messageCounter = 0; + $lastIndex = null; + + try { + $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); + + foreach ($stream as $index => $message) { + $messageCounter++; + $lastIndex = $index; + + foreach ($subscriptions as $subscription) { + if ($subscription->position() >= $index) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue booting.', + $subscription->id(), + $subscription->position(), + $index, + ), + ); + + continue; + } + + $error = $this->messageProcessor->process($index, $message, $subscription); + + if (!$error) { + continue; + } + + $errors[] = $error; + + $subscriptions->remove($subscription); + + if (count($subscriptions) === 0) { + $this->logger?->info( + 'Subscription Engine: No subscriptions in booting status, finish booting.', + ); + + break 2; + } + } + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Current event stream position for booting: %s', + $index, + ), + ); + + if ($command->limit !== null && $messageCounter >= $command->limit) { + $this->logger?->info( + sprintf( + 'Subscription Engine: Message limit (%d) reached, finish booting.', + $command->limit, + ), + ); + + $limitEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_LIMIT_REACHED, + $messageCounter, + $lastIndex, + ); + $this->eventDispatcher->dispatch($limitEvent); + $errors = array_merge($errors, $limitEvent->errors); + + return new ProcessedResult( + $messageCounter, + false, + $errors, + ); + } + } + + $finishedEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_STREAM_ENDED, + $messageCounter, + $lastIndex, + ); + $this->eventDispatcher->dispatch($finishedEvent); + $errors = array_merge($errors, $finishedEvent->errors); + } finally { + $stream?->close(); + + if ($lastIndex !== null && $messageCounter > 0) { + foreach ($subscriptions as $subscription) { + $this->subscriptionManager->update($subscription); + } + } + } + + $this->logger?->debug('Subscription Engine: End of stream for booting has been reached.'); + + foreach ($subscriptions as $subscription) { + if ($subscription->status() !== Status::Booting) { + continue; + } + + if ($subscription->runMode() === RunMode::Once) { + $subscription->finished(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', + $subscription->id(), + )); + + continue; + } + + $subscription->active(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" has been set to active after booting.', + $subscription->id(), + )); + } + + return new ProcessedResult( + $messageCounter, + true, + $errors, + ); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/Handler.php b/src/Subscription/Engine/Handler/Handler.php new file mode 100644 index 000000000..cab82d44a --- /dev/null +++ b/src/Subscription/Engine/Handler/Handler.php @@ -0,0 +1,16 @@ + + */ +final class PauseHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [ + Status::Active, + Status::Booting, + Status::Error, + ], + ), + function (SubscriptionCollection $subscriptions): Result { + /** @var Subscription $subscription */ + foreach ($subscriptions as $subscription) { + $subscription->pause(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" is paused.', + $subscription->id(), + )); + } + + return new Result(); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/ReactivateHandler.php b/src/Subscription/Engine/Handler/ReactivateHandler.php new file mode 100644 index 000000000..170ae3549 --- /dev/null +++ b/src/Subscription/Engine/Handler/ReactivateHandler.php @@ -0,0 +1,93 @@ + + */ +final class ReactivateHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [ + Status::Error, + Status::Failed, + Status::Detached, + Status::Paused, + Status::Finished, + ], + ), + function (SubscriptionCollection $subscriptions): Result { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found, skipped.', + $subscription->id(), + ), + ); + + continue; + } + + $error = $subscription->subscriptionError(); + + if ($error) { + $subscription->doRetry(); + $subscription->resetRetry(); + + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); + + continue; + } + + $subscription->active(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); + } + + return new Result(); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php new file mode 100644 index 000000000..5931a5524 --- /dev/null +++ b/src/Subscription/Engine/Handler/RefreshHandler.php @@ -0,0 +1,116 @@ + + */ +final class RefreshHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + )); + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + continue; + } + + $changed = false; + + if ($subscription->runMode() !== $subscriber->metadata()->runMode) { + $changed = true; + $oldRunMode = $subscription->runMode(); + $subscription->changeRunMode($subscriber->metadata()->runMode); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" run mode changed from "%s" to "%s".', + $subscription->id(), + $oldRunMode->value, + $subscription->runMode()->value, + ), + ); + } + + if ($subscription->group() !== $subscriber->metadata()->group) { + $changed = true; + $oldGroup = $subscription->group(); + $subscription->changeGroup($subscriber->metadata()->group); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" group changed from "%s" to "%s".', + $subscription->id(), + $oldGroup, + $subscription->group(), + ), + ); + } + + $cleanupTasks = $this->cleanupTasks($subscriber); + + if ($subscription->cleanupTasks() !== $cleanupTasks) { + $changed = true; + $subscription->replaceCleanupTasks($cleanupTasks); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" cleanup tasks changed.', + $subscription->id(), + ), + ); + } + + if (!$changed) { + continue; + } + + $this->subscriptionManager->update($subscription); + } + + $this->subscriptionManager->flush(); + + return new Result(); + } + + /** @return list|null */ + private function cleanupTasks(MetadataSubscriberAccessor $subscriber): array|null + { + $method = $subscriber->cleanupMethod(); + + if (!$method) { + return null; + } + + return array_values([...$method()]); + } +} diff --git a/src/Subscription/Engine/Handler/RemoveHandler.php b/src/Subscription/Engine/Handler/RemoveHandler.php new file mode 100644 index 000000000..4e43859e7 --- /dev/null +++ b/src/Subscription/Engine/Handler/RemoveHandler.php @@ -0,0 +1,127 @@ + + */ +final class RemoveHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly CleanupRunner $cleanupRunner, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + ), + function (SubscriptionCollection $subscriptions): Result { + /** @var list $errors */ + $errors = []; + + foreach ($subscriptions as $subscription) { + if ($subscription->isNew()) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + ), + ); + + continue; + } + + if ($subscription->hasCleanupTasks()) { + $error = $this->cleanupRunner->cleanup($subscription, true); + + if ($error) { + $errors[] = $error; + } + + continue; + } + + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed without a suitable subscriber.', + $subscription->id(), + ), + ); + + continue; + } + + $teardownMethod = $subscriber->teardownMethod(); + + if (!$teardownMethod) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), + ); + + continue; + } + + try { + $teardownMethod(); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscriber "%s" teardown method could not be executed: %s', + $subscriber::class, + $e->getMessage(), + ), + ); + + $errors[] = new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), + ); + } + + return new Result($errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php new file mode 100644 index 000000000..8e8f8318f --- /dev/null +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -0,0 +1,181 @@ + + */ +final class RunHandler implements Handler +{ + public function __construct( + private readonly MessageLoader $messageLoader, + private readonly SubscriptionManager $subscriptionManager, + private readonly MessageProcessor $messageProcessor, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): ProcessedResult + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Active], + ), + function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions to process, finish processing.'); + + return new ProcessedResult(0, true); + } + + $startIndex = $subscriptions->lowestPosition(); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Event stream is processed from position %d.', + $startIndex, + ), + ); + + /** @var list $errors */ + $errors = []; + $stream = null; + $messageCounter = 0; + $lastIndex = null; + + try { + $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); + + foreach ($stream as $index => $message) { + $messageCounter++; + $lastIndex = $index; + + foreach ($subscriptions as $subscription) { + if ($subscription->position() >= $index) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue processing.', + $subscription->id(), + $subscription->position(), + $index, + ), + ); + + continue; + } + + $error = $this->messageProcessor->process($index, $message, $subscription); + + if (!$error) { + continue; + } + + $errors[] = $error; + + $subscriptions->remove($subscription); + + if (count($subscriptions) === 0) { + $this->logger?->info( + 'Subscription Engine: No subscriptions in active status, finish processing.', + ); + + break 2; + } + } + + $this->logger?->debug(sprintf( + 'Subscription Engine: Current event stream position: %s', + $index, + )); + + if ($command->limit !== null && $messageCounter >= $command->limit) { + $this->logger?->info( + sprintf( + 'Subscription Engine: Message limit (%d) reached, finish processing.', + $command->limit, + ), + ); + + $limitEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_LIMIT_REACHED, + $messageCounter, + $lastIndex, + ); + $this->eventDispatcher->dispatch($limitEvent); + $errors = array_merge($errors, $limitEvent->errors); + + return new ProcessedResult($messageCounter, false, $errors); + } + } + + $finishedEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_STREAM_ENDED, + $messageCounter, + $lastIndex, + ); + $this->eventDispatcher->dispatch($finishedEvent); + $errors = array_merge($errors, $finishedEvent->errors); + } finally { + $stream?->close(); + + if ($lastIndex !== null && $messageCounter > 0) { + foreach ($subscriptions as $subscription) { + $this->subscriptionManager->update($subscription); + } + } + } + + foreach ($subscriptions as $subscription) { + if ($subscription->runMode() !== RunMode::Once) { + continue; + } + + $subscription->finished(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', + $subscription->id(), + )); + } + + $this->logger?->info( + sprintf( + 'Subscription Engine: End of stream on position "%d" has been reached, finish processing.', + $lastIndex, + ), + ); + + return new ProcessedResult($messageCounter, true, $errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php new file mode 100644 index 000000000..00c3e3559 --- /dev/null +++ b/src/Subscription/Engine/Handler/SetupHandler.php @@ -0,0 +1,150 @@ + + */ +final class SetupHandler implements Handler +{ + public function __construct( + private readonly MessageLoader $messageLoader, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly RetryStrategyRepository $retryStrategyRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::New], + ), + function (SubscriptionCollection $subscriptions) use ($command): Result { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions to setup, finish setup.'); + + return new Result(); + } + + /** @var list $errors */ + $errors = []; + + $latestIndex = $this->messageLoader->lastIndex(); + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + throw SubscriberNotFound::forSubscriptionId($subscription->id()); + } + + $setupMethod = $subscriber->setupMethod(); + + if (!$setupMethod) { + if ($subscription->runMode() === RunMode::FromNow) { + $subscription->changePosition($latestIndex); + $subscription->active(); + } else { + $command->skipBooting ? $subscription->active() : $subscription->booting(); + } + + $this->subscriptionManager->update($subscription); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no setup method, set to %s.', + $subscriber::class, + $subscription->id(), + $subscription->runMode() === RunMode::FromNow || $command->skipBooting ? 'active' : 'booting', + )); + + continue; + } + + try { + $setupMethod(); + + if ($subscription->runMode() === RunMode::FromNow) { + $subscription->changePosition($latestIndex); + $subscription->active(); + } else { + $command->skipBooting ? $subscription->active() : $subscription->booting(); + } + + $this->subscriptionManager->update($subscription); + + $this->logger?->debug(sprintf( + 'Subscription Engine: For Subscriber "%s" for "%s" the setup method has been executed, set to %s.', + $subscriber::class, + $subscription->id(), + $subscription->runMode() === RunMode::FromNow || $command->skipBooting ? 'active' : 'booting', + )); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has an error in the setup method: %s', + $subscriber::class, + $subscription->id(), + $e->getMessage(), + )); + + $this->handleError($subscription, $e); + + $errors[] = new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + } + + return new Result($errors); + }, + ); + } + + private function handleError(Subscription $subscription, Throwable $throwable): void + { + $subscriber = $this->subscriberRepository->get($subscription->id()); + $retryStrategy = $subscriber instanceof MetadataSubscriberAccessor && $subscriber->metadata()->retryStrategy !== null + ? $this->retryStrategyRepository->get($subscriber->metadata()->retryStrategy) + : $this->retryStrategyRepository->getDefaultRetryStrategy(); + + if (!$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($subscription)) { + $subscription->error($throwable); + } else { + $subscription->failed($throwable); + } + + $this->subscriptionManager->update($subscription); + } +} diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php new file mode 100644 index 000000000..51a14c593 --- /dev/null +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -0,0 +1,130 @@ + + */ +final class TeardownHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly CleanupRunner $cleanupRunner, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Detached], + ), + function (SubscriptionCollection $subscriptions): Result { + /** @var list $errors */ + $errors = []; + + foreach ($subscriptions as $subscription) { + if ($subscription->hasCleanupTasks()) { + $error = $this->cleanupRunner->cleanup($subscription); + + if ($error) { + $errors[] = $error; + } + + continue; + } + + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $this->logger?->warning( + sprintf( + 'Subscription Engine: Subscriber for "%s" to teardown or cleanup not found, skipped.', + $subscription->id(), + ), + ); + + continue; + } + + $teardownMethod = $subscriber->teardownMethod(); + + if (!$teardownMethod) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no teardown method and was immediately removed.', + $subscriber::class, + $subscription->id(), + ), + ); + + continue; + } + + try { + $teardownMethod(); + + $this->logger?->debug(sprintf( + 'Subscription Engine: For Subscriber "%s" for "%s" the teardown method has been executed and is now prepared to be removed.', + $subscriber::class, + $subscription->id(), + )); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscription "%s" for "%s" has an error in the teardown method, skipped: %s', + $subscriber::class, + $subscription->id(), + $e->getMessage(), + ), + ); + + $errors[] = new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + + continue; + } + + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + ), + ); + } + + return new Result($errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/Listener/BatchSubscriber.php b/src/Subscription/Engine/Listener/BatchSubscriber.php new file mode 100644 index 000000000..1f06d0fe3 --- /dev/null +++ b/src/Subscription/Engine/Listener/BatchSubscriber.php @@ -0,0 +1,194 @@ + */ + private array $batching = []; + + public function __construct( + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function onCommand(OnCommand $event): void + { + $this->batching = []; + } + + public function onHandleMessage(OnHandleMessage $event): void + { + $subscriberId = $event->subscription->id(); + + if (isset($this->batching[$subscriberId])) { + return; + } + + $subscriber = $this->subscriberRepository->get($subscriberId); + + if (!$subscriber) { + return; + } + + $realSubscriber = $subscriber->subscriber(); + + if (!$realSubscriber instanceof BatchableSubscriber) { + return; + } + + $this->batching[$subscriberId] = [ + 'subscriber' => $realSubscriber, + 'subscription' => $event->subscription, + ]; + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" starts a new batch.', + $subscriberId, + )); + + try { + $realSubscriber->beginBatch(); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the begin batch method: %s', + $subscriberId, + $e->getMessage(), + )); + + throw $e; + } + } + + public function onHandleMessageSuccess(OnHandleMessageSuccess $event): void + { + $subscriberId = $event->subscription->id(); + + if (!isset($this->batching[$subscriberId])) { + return; + } + + if (!$this->shouldCommitBatch($event->subscription)) { + $event->shouldChangePosition = false; + + return; + } + + $subscriber = $this->batching[$subscriberId]['subscriber']; + unset($this->batching[$subscriberId]); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" commits the batch.', + $subscriberId, + )); + + try { + $subscriber->commitBatch(); + $event->shouldChangePosition = true; + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the commit batch method: %s', + $subscriberId, + $e->getMessage(), + )); + + throw $e; + } + } + + public function onProcessingFinished(OnProcessingFinished $event): void + { + $lastIndex = $event->lastIndex; + + if ($lastIndex === null) { + return; + } + + foreach ($this->batching as $subscriberId => ['subscriber' => $subscriber, 'subscription' => $subscription]) { + unset($this->batching[$subscriberId]); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" commits the batch.', + $subscriberId, + )); + + try { + $subscriber->commitBatch(); + $subscription->changePosition($lastIndex); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the commit batch method: %s', + $subscriberId, + $e->getMessage(), + )); + + $subscription->error($e); + $event->errors[] = new Error($subscriberId, $e->getMessage(), $e); + } + } + } + + private function shouldCommitBatch(Subscription $subscription): bool + { + return $this->batching[$subscription->id()]['subscriber']->forceCommit(); + } + + public function onError(OnHandleMessageError $event): void + { + $subscriptionId = $event->subscription->id(); + + if (!isset($this->batching[$subscriptionId])) { + return; + } + + $subscriber = $this->batching[$subscriptionId]['subscriber']; + + unset($this->batching[$subscriptionId]); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" rollback the batch.', + $subscriptionId, + )); + + try { + $subscriber->rollbackBatch(); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the rollback batch method: %s', + $subscriptionId, + $e->getMessage(), + )); + } + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => 'onCommand', + OnHandleMessage::class => 'onHandleMessage', + OnHandleMessageSuccess::class => 'onHandleMessageSuccess', + OnHandleMessageError::class => 'onError', + OnProcessingFinished::class => 'onProcessingFinished', + ]; + } +} diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php new file mode 100644 index 000000000..930db2c55 --- /dev/null +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -0,0 +1,63 @@ +command; + + if (!$command instanceof Run) { + return; + } + + $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Active, Status::Paused, Status::Finished], + ), + function (SubscriptionCollection $subscriptions): void { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if ($subscriber) { + continue; + } + + $subscription->detached(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found and has been marked as detached.', + $subscription->id(), + ), + ); + } + }, + ); + } +} diff --git a/src/Subscription/Engine/Listener/DiscoverSubscriber.php b/src/Subscription/Engine/Listener/DiscoverSubscriber.php new file mode 100644 index 000000000..c02cfd788 --- /dev/null +++ b/src/Subscription/Engine/Listener/DiscoverSubscriber.php @@ -0,0 +1,105 @@ +discover(); + } + + public function onSubscriptions(OnSubscriptions $event): void + { + $this->discover(); + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => ['onCommand', 64], + OnSubscriptions::class => 'onSubscriptions', + ]; + } + + private function discover(): void + { + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria()); + + $latestIndex = null; + + foreach ($this->subscriberRepository->all() as $subscriber) { + foreach ($subscriptions as $subscription) { + if ($subscription->id() === $subscriber->metadata()->id) { + continue 2; + } + } + + $subscription = new Subscription( + $subscriber->metadata()->id, + $subscriber->metadata()->group, + $subscriber->metadata()->runMode, + cleanupTasks: $this->cleanupTasks($subscriber), + ); + + if ($subscriber->setupMethod() === null && $subscriber->metadata()->runMode === RunMode::FromNow) { + if ($latestIndex === null) { + $latestIndex = $this->messageLoader->lastIndex(); + } + + $subscription->changePosition($latestIndex); + $subscription->active(); + } + + $this->subscriptionManager->add($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: New Subscriber "%s" was found and added to the subscription store.', + $subscriber->metadata()->id, + ), + ); + } + + $this->subscriptionManager->flush(); + } + + /** @return list|null */ + private function cleanupTasks(MetadataSubscriberAccessor $subscriber): array|null + { + $method = $subscriber->cleanupMethod(); + + if (!$method) { + return null; + } + + return array_values([...$method()]); + } +} diff --git a/src/Subscription/Engine/Listener/FailSubscriber.php b/src/Subscription/Engine/Listener/FailSubscriber.php new file mode 100644 index 000000000..48c7b63f1 --- /dev/null +++ b/src/Subscription/Engine/Listener/FailSubscriber.php @@ -0,0 +1,101 @@ +failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + if ($subscriber->subscriber() instanceof BatchableSubscriber) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + $failedMethod = $subscriber->failedMethod(); + + if (!$failedMethod) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + try { + $failedMethod($message, $throwable); + $subscription->changePosition($index); + $subscription->resetRetry(); + + $this->subscriptionManager->update($subscription); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the failed method: %s', + $subscription->id(), + $e->getMessage(), + )); + + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + } + } + + public function onHandleMessageError(OnHandleMessageError $event): void + { + if (!$event->transitionToFailed) { + return; + } + + $this->handleFailed($event->subscription, $event->throwable, $event->message, $event->index); + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + return [ + OnHandleMessageError::class => ['onHandleMessageError', -8], + ]; + } +} diff --git a/src/Subscription/Engine/Listener/RetrySubscriber.php b/src/Subscription/Engine/Listener/RetrySubscriber.php new file mode 100644 index 000000000..4a7758912 --- /dev/null +++ b/src/Subscription/Engine/Listener/RetrySubscriber.php @@ -0,0 +1,138 @@ +command; + + $status = match ($command::class) { + Setup::class => Status::New, + Boot::class => Status::Booting, + Run::class => Status::Active, + default => null, + }; + + if ($status === null) { + return; + } + + $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Error], + ), + function (SubscriptionCollection $subscriptions) use ($status): void { + /** @var Subscription $subscription */ + foreach ($subscriptions as $subscription) { + $error = $subscription->subscriptionError(); + + if ($error === null) { + continue; + } + + if ($error->previousStatus !== $status) { + continue; + } + + if (!$this->retryStrategy($subscription)->shouldRetry($subscription)) { + continue; + } + + $subscription->doRetry(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Retry subscription "%s" (%d) and set back to %s.', + $subscription->id(), + $subscription->retryAttempt(), + $subscription->status()->value, + ), + ); + } + }, + ); + } + + public function onHandleMessageError(OnHandleMessageError $event): void + { + $retryStrategy = $this->retryStrategy($event->subscription); + + if (!$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($event->subscription)) { + $event->subscription->error($event->throwable); + $this->subscriptionManager->update($event->subscription); + + return; + } + + $event->transitionToFailed = true; + } + + public function onSuccessHandleMessage(OnHandleMessageSuccess $event): void + { + $event->subscription->resetRetry(); + } + + private function retryStrategy(Subscription $subscription): RetryStrategy + { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber instanceof MetadataSubscriberAccessor) { + return $this->retryStrategyRepository->getDefaultRetryStrategy(); + } + + $retryStrategy = $subscriber->metadata()->retryStrategy; + + if ($retryStrategy === null) { + return $this->retryStrategyRepository->getDefaultRetryStrategy(); + } + + return $this->retryStrategyRepository->get($retryStrategy); + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => ['onCommand', 16], + OnHandleMessageError::class => 'onHandleMessageError', + OnHandleMessageSuccess::class => 'onSuccessHandleMessage', + ]; + } +} diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php new file mode 100644 index 000000000..534f76a0a --- /dev/null +++ b/src/Subscription/Engine/MessageProcessor.php @@ -0,0 +1,147 @@ +subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + throw SubscriberNotFound::forSubscriptionId($subscription->id()); + } + + $subscribeMethods = $subscriber->subscribeMethods($message->event()::class); + + if ($subscribeMethods === []) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no subscribe methods for "%s", continue.', + $subscriber::class, + $subscription->id(), + $message->event()::class, + ), + ); + + $event = new OnHandleMessageSuccess($subscription, $message, $index); + $this->eventDispatcher->dispatch($event); + + if ($event->shouldChangePosition) { + $subscription->changePosition($index); + } + + return null; + } + + try { + $event = new OnHandleMessage( + $subscription, + $message, + ); + + $this->eventDispatcher->dispatch($event); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', + $subscriber::class, + $subscription->id(), + $message->event()::class, + $e->getMessage(), + ), + ); + + $this->eventDispatcher->dispatch( + new OnHandleMessageError( + $subscription, + $e, + $message, + $index, + ), + ); + + return new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + try { + foreach ($subscribeMethods as $subscribeMethod) { + $subscribeMethod($message); + } + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', + $subscriber::class, + $subscription->id(), + $message->event()::class, + $e->getMessage(), + ), + ); + + $this->eventDispatcher->dispatch( + new OnHandleMessageError( + $subscription, + $e, + $message, + $index, + ), + ); + + return new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + $event = new OnHandleMessageSuccess( + $subscription, + $message, + $index, + ); + + $this->eventDispatcher->dispatch($event); + + if ($event->shouldChangePosition) { + $subscription->changePosition($index); + } + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', + $subscriber::class, + $subscription->id(), + $message->event()::class, + ), + ); + + return null; + } +} diff --git a/src/Subscription/Engine/ProcessedResult.php b/src/Subscription/Engine/ProcessedResult.php index d34f6c97d..be31a7f89 100644 --- a/src/Subscription/Engine/ProcessedResult.php +++ b/src/Subscription/Engine/ProcessedResult.php @@ -4,13 +4,14 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -final class ProcessedResult +final class ProcessedResult extends Result { /** @param list $errors */ public function __construct( public readonly int $processedMessages, public readonly bool $finished = false, - public readonly array $errors = [], + array $errors = [], ) { + parent::__construct($errors); } } diff --git a/src/Subscription/Engine/Result.php b/src/Subscription/Engine/Result.php index d644bb17d..3b0207337 100644 --- a/src/Subscription/Engine/Result.php +++ b/src/Subscription/Engine/Result.php @@ -4,7 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -final class Result +class Result { /** @param list $errors */ public function __construct( diff --git a/src/Subscription/Engine/SubscriptionEngine.php b/src/Subscription/Engine/SubscriptionEngine.php index 3e18be635..b081695f3 100644 --- a/src/Subscription/Engine/SubscriptionEngine.php +++ b/src/Subscription/Engine/SubscriptionEngine.php @@ -4,41 +4,13 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Subscription; interface SubscriptionEngine { - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result; - - /** - * @param positive-int|null $limit - * - * @throws SubscriberNotFound - * @throws AlreadyProcessing - */ - public function boot( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult; - - /** - * @param positive-int|null $limit - * - * @throws SubscriberNotFound - * @throws AlreadyProcessing - */ - public function run( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult; - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result; - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result; - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result; - - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result; + /** @throws AlreadyProcessing */ + public function execute(Command $command): Result; /** @return list */ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array; diff --git a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php index 2066bd1c2..054e8a195 100644 --- a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php +++ b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php @@ -4,51 +4,26 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -use LogicException; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Subscription; -use function sprintf; - -final class ThrowOnErrorSubscriptionEngine implements SubscriptionEngine, CanRefreshSubscriptions +final class ThrowOnErrorSubscriptionEngine implements SubscriptionEngine { public function __construct( private readonly SubscriptionEngine $parent, ) { } - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result - { - return $this->throwOnError($this->parent->setup($criteria, $skipBooting)); - } - - public function boot(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - return $this->throwOnError($this->parent->boot($criteria, $limit)); - } - - public function run(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - return $this->throwOnError($this->parent->run($criteria, $limit)); - } - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result + public function execute(Command $command): Result { - return $this->throwOnError($this->parent->teardown($criteria)); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->throwOnError($this->parent->remove($criteria)); - } + $result = $this->parent->execute($command); + $errors = $result->errors; - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->throwOnError($this->parent->reactivate($criteria)); - } + if ($errors !== []) { + throw new ErrorDetected($errors); + } - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->throwOnError($this->parent->pause($criteria)); + return $result; } /** @return list */ @@ -56,35 +31,4 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): { return $this->parent->subscriptions($criteria); } - - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result - { - if (!$this->parent instanceof CanRefreshSubscriptions) { - throw new LogicException(sprintf( - '"%s" does not implement "%s" and cannot call refresh.', - $this->parent::class, - CanRefreshSubscriptions::class, - )); - } - - return $this->throwOnError($this->parent->refresh($criteria)); - } - - /** - * @param T $result - * - * @return T - * - * @template T of Result|ProcessedResult - */ - private function throwOnError(Result|ProcessedResult $result): Result|ProcessedResult - { - $errors = $result->errors; - - if ($errors !== []) { - throw new ErrorDetected($errors); - } - - return $result; - } } diff --git a/src/Subscription/Repository/RunSubscriptionEngineRepository.php b/src/Subscription/Repository/RunSubscriptionEngineRepository.php index 2f1f39682..fc66fde67 100644 --- a/src/Subscription/Repository/RunSubscriptionEngineRepository.php +++ b/src/Subscription/Repository/RunSubscriptionEngineRepository.php @@ -8,8 +8,8 @@ use Patchlevel\EventSourcing\Identifier\Identifier; use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Subscription\Engine\AlreadyProcessing; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** * @template T of AggregateRoot @@ -49,12 +49,12 @@ public function save(AggregateRoot $aggregate): void $this->repository->save($aggregate); try { - $this->engine->run( - new SubscriptionEngineCriteria( + $this->engine->execute( + new Run( $this->ids, $this->groups, + $this->limit, ), - $this->limit, ); } catch (AlreadyProcessing) { // do nothing diff --git a/tests/Architecture/FinalClassesTest.php b/tests/Architecture/FinalClassesTest.php index e9b4c65d9..b03094b5f 100644 --- a/tests/Architecture/FinalClassesTest.php +++ b/tests/Architecture/FinalClassesTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Architecture; use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Result; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule; use PHPat\Test\PHPat; @@ -20,6 +21,7 @@ public function testFinalClasses(): Rule Selector::NOT(Selector::isAbstract()), Selector::NOT(Selector::isInterface()), Selector::NOT(Selector::classname(Subscriber::class)), + Selector::NOT(Selector::classname(Result::class)), ), ) ->shouldBeFinal(); diff --git a/tests/Benchmark/CommandToQueryBench.php b/tests/Benchmark/CommandToQueryBench.php index e6695a4e1..43f1f5893 100644 --- a/tests/Benchmark/CommandToQueryBench.php +++ b/tests/Benchmark/CommandToQueryBench.php @@ -18,6 +18,7 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; @@ -89,7 +90,7 @@ public function setUp(): void $schemaDirector = new DoctrineSchemaDirector($connection, $store); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $this->updateId = ProfileId::generate(); $this->commandBus->dispatch(new CreateProfile($this->updateId, 'Peter')); diff --git a/tests/Benchmark/SubscriptionEngineBatchBench.php b/tests/Benchmark/SubscriptionEngineBatchBench.php index f610ce9b2..39d2e5bfa 100644 --- a/tests/Benchmark/SubscriptionEngineBatchBench.php +++ b/tests/Benchmark/SubscriptionEngineBatchBench.php @@ -12,6 +12,9 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; @@ -84,8 +87,8 @@ public function setUp(): void #[Bench\Revs(10)] public function benchHandle10000Events(): void { - $this->subscriptionEngine->setup(); - $this->subscriptionEngine->boot(); - $this->subscriptionEngine->remove(); + $this->subscriptionEngine->execute(new Setup()); + $this->subscriptionEngine->execute(new Boot()); + $this->subscriptionEngine->execute(new Remove()); } } diff --git a/tests/Benchmark/SubscriptionEngineBench.php b/tests/Benchmark/SubscriptionEngineBench.php index 70783e7e8..6ec636e0e 100644 --- a/tests/Benchmark/SubscriptionEngineBench.php +++ b/tests/Benchmark/SubscriptionEngineBench.php @@ -13,6 +13,9 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; @@ -92,8 +95,8 @@ public function setUp(): void #[Bench\Revs(10)] public function benchHandle10000Events(): void { - $this->subscriptionEngine->setup(); - $this->subscriptionEngine->boot(); - $this->subscriptionEngine->remove(); + $this->subscriptionEngine->execute(new Setup()); + $this->subscriptionEngine->execute(new Boot()); + $this->subscriptionEngine->execute(new Remove()); } } diff --git a/tests/Integration/BankAccountSplitStream/IntegrationTest.php b/tests/Integration/BankAccountSplitStream/IntegrationTest.php index 5e1f8be12..e6b03db65 100644 --- a/tests/Integration/BankAccountSplitStream/IntegrationTest.php +++ b/tests/Integration/BankAccountSplitStream/IntegrationTest.php @@ -13,6 +13,9 @@ use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; @@ -74,8 +77,8 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->setup(); - $engine->boot(); + $engine->execute(new Setup()); + $engine->execute(new Boot()); $bankAccountId = AccountId::generate(); $bankAccount = BankAccount::create($bankAccountId, 'John'); @@ -83,7 +86,7 @@ public function testSuccessful(): void $bankAccount->addBalance(500); $repository->save($bankAccount); - $engine->run(); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -122,7 +125,7 @@ public function testSuccessful(): void $bankAccount->addBalance(200); $repository->save($bankAccount); - $engine->run(); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -189,8 +192,8 @@ public function testRemoveArchived(): void ); $schemaDirector->create(); - $engine->setup(); - $engine->boot(); + $engine->execute(new Setup()); + $engine->execute(new Boot()); $bankAccountId = AccountId::generate(); $bankAccount = BankAccount::create($bankAccountId, 'John'); @@ -198,7 +201,7 @@ public function testRemoveArchived(): void $bankAccount->addBalance(500); $repository->save($bankAccount); - $engine->run(); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -237,7 +240,7 @@ public function testRemoveArchived(): void $bankAccount->addBalance(200); $repository->save($bankAccount); - $engine->run(); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index 45a1f1b1c..8d0bbcf75 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -24,6 +24,7 @@ use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; @@ -100,7 +101,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -166,7 +167,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -300,7 +301,7 @@ public function testCommandBus(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); @@ -380,7 +381,7 @@ public function testQueryBus(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); diff --git a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php index 7fac99b8b..ae4bb3336 100644 --- a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php +++ b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php @@ -12,6 +12,7 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; @@ -76,7 +77,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -142,7 +143,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); diff --git a/tests/Integration/PersonalData/PersonalDataTest.php b/tests/Integration/PersonalData/PersonalDataTest.php index d9b83696f..3c71f04be 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/PersonalData/PersonalDataTest.php @@ -16,6 +16,8 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; @@ -132,13 +134,13 @@ public function testRemoveKeyWithEvent(): void new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), ); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $engine->run(); + $engine->execute(new Run()); $profile = $repository->load($profileId); @@ -149,7 +151,7 @@ public function testRemoveKeyWithEvent(): void $profile->removePersonalData(); $repository->save($profile); - $engine->run(); + $engine->execute(new Run()); $profile = $repository->load($profileId); @@ -214,14 +216,14 @@ public function testRemoveKeyWithEventAndSnapshot(): void new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), ); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $profile->changeName('John 2'); $repository->save($profile); - $engine->run(); + $engine->execute(new Run()); $profile = $repository->load($profileId); diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index eaffc32d2..6da6843d2 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -24,12 +24,20 @@ use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropTableTask; use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner; use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup as SetupCommand; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown as TeardownCommand; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; +use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -123,13 +131,13 @@ public function testHappyPath(): void $engine->subscriptions(), ); - $result = $engine->setup(); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -149,9 +157,9 @@ public function testHappyPath(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -178,7 +186,7 @@ public function testHappyPath(): void self::assertSame($profileId->toString(), $result['id']); self::assertSame('John', $result['name']); - $result = $engine->remove(); + $result = $engine->execute(new Remove()); self::assertEquals([], $result->errors); self::assertEquals( @@ -249,13 +257,13 @@ public function testGapResolver(): void $engine->subscriptions(), ); - $result = $engine->setup(); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -275,9 +283,9 @@ public function testGapResolver(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -304,7 +312,7 @@ public function testGapResolver(): void self::assertSame($profileId->toString(), $result['id']); self::assertSame('John', $result['name']); - $result = $engine->remove(); + $result = $engine->execute(new Remove()); self::assertEquals([], $result->errors); self::assertEquals( @@ -370,11 +378,11 @@ public function testErrorHandling(): void ), ); - $result = $engine->setup(); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); - self::assertEquals(0, $result->processedMessages); + $result = $engine->execute(new Boot()); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -392,9 +400,9 @@ public function testErrorHandling(): void // first run, error - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -411,9 +419,9 @@ public function testErrorHandling(): void // second run, time has not passed yet, no retry, no error - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -426,9 +434,9 @@ public function testErrorHandling(): void // third run, time has passed, 1. retry, error again $clock->sleep(5); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -446,9 +454,9 @@ public function testErrorHandling(): void // fourth run, time has passed, 2. retry, max retries reached, failed $clock->sleep(10); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -466,9 +474,9 @@ public function testErrorHandling(): void // fifth run, time has passed, skip failed subscription $clock->sleep(20); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -480,7 +488,7 @@ public function testErrorHandling(): void // reactivated subscription - $engine->reactivate(new SubscriptionEngineCriteria( + $engine->execute(new Reactivate( ids: ['error_producer'], )); @@ -492,9 +500,9 @@ public function testErrorHandling(): void // sixth run, error again - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -514,9 +522,9 @@ public function testErrorHandling(): void $clock->sleep(5); $subscriber->subscribeError = false; - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -571,7 +579,7 @@ public function testSelfRecovery(): void ), ); - $result = $engine->setup(skipBooting: true); + $result = $engine->execute(new SetupCommand(skipBooting: true)); self::assertEquals([], $result->errors); // add data @@ -585,9 +593,9 @@ public function testSelfRecovery(): void // first run, failed -> self recovery - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -609,9 +617,9 @@ public function testSelfRecovery(): void // second run, failed -> self recovery failed $subscriber->onFailedError = true; - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -692,11 +700,11 @@ public function subscribe(): void ), ); - $result = $engine->setup(); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); - self::assertEquals(0, $result->processedMessages); + $result = $engine->execute(new Boot()); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -712,9 +720,9 @@ public function subscribe(): void $subscriber->subscribeError = true; - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -795,7 +803,7 @@ public function testProcessor(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $engine->run(); + $engine->execute(new Run()); $subscriptions = $engine->subscriptions(); @@ -856,8 +864,8 @@ public function testBlueGreenDeployment(): void // Deploy first version - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -877,7 +885,7 @@ public function testBlueGreenDeployment(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -901,8 +909,8 @@ public function testBlueGreenDeployment(): void new MetadataSubscriberAccessorRepository([new ProfileNewProjection($this->projectionConnection)]), ); - $secondEngine->setup(); - $secondEngine->boot(); + $secondEngine->execute(new SetupCommand()); + $secondEngine->execute(new Boot()); self::assertEquals( [ @@ -928,7 +936,7 @@ public function testBlueGreenDeployment(): void // switch traffic - $secondEngine->run(); + $secondEngine->execute(new Run()); self::assertEquals( [ @@ -954,7 +962,7 @@ public function testBlueGreenDeployment(): void // shutdown first version - $firstEngine->teardown(); + $firstEngine->execute(new TeardownCommand()); self::assertEquals( [ @@ -1012,8 +1020,8 @@ public function testBlueGreenDeploymentRollback(): void // Deploy first version - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -1033,7 +1041,7 @@ public function testBlueGreenDeploymentRollback(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -1057,8 +1065,8 @@ public function testBlueGreenDeploymentRollback(): void new MetadataSubscriberAccessorRepository([new ProfileNewProjection($this->projectionConnection)]), ); - $secondEngine->setup(); - $secondEngine->boot(); + $secondEngine->execute(new SetupCommand()); + $secondEngine->execute(new Boot()); self::assertEquals( [ @@ -1084,7 +1092,7 @@ public function testBlueGreenDeploymentRollback(): void // switch traffic - $secondEngine->run(); + $secondEngine->execute(new Run()); self::assertEquals( [ @@ -1110,8 +1118,8 @@ public function testBlueGreenDeploymentRollback(): void // rollback - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -1137,13 +1145,13 @@ public function testBlueGreenDeploymentRollback(): void // reactivating detached subscription - $firstEngine->reactivate(new SubscriptionEngineCriteria( + $firstEngine->execute(new Reactivate( ids: ['profile_1'], )); // switch traffic - $firstEngine->run(); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -1169,7 +1177,7 @@ public function testBlueGreenDeploymentRollback(): void // shutdown second version - $secondEngine->teardown(); + $secondEngine->execute(new TeardownCommand()); self::assertEquals( [ @@ -1234,8 +1242,8 @@ public function testCleanup(): void // Deploy first version - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -1256,7 +1264,7 @@ public function testCleanup(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -1282,8 +1290,8 @@ public function testCleanup(): void cleaner: $cleaner, ); - $secondEngine->setup(); - $secondEngine->boot(); + $secondEngine->execute(new SetupCommand()); + $secondEngine->execute(new Boot()); self::assertEquals( [ @@ -1310,7 +1318,7 @@ public function testCleanup(): void // switch traffic - $secondEngine->run(); + $secondEngine->execute(new Run()); self::assertEquals( [ @@ -1337,7 +1345,7 @@ public function testCleanup(): void // shutdown second version (with cleanup) - $secondEngine->teardown(); + $secondEngine->execute(new TeardownCommand()); self::assertEquals( [ @@ -1410,22 +1418,22 @@ public function testLookup(): void $subscriberRepository, ); - $result = $engine->setup(); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); $result = $this->projectionConnection->fetchAssociative( @@ -1439,9 +1447,9 @@ public function testLookup(): void $profile->promoteToAdmin(); $repository->save($profile); - $result = $engine->run(); + $result = $engine->execute(new Run()); - self::assertEquals(2, $result->processedMessages); + self::assertProcessedMessages(2, $result); self::assertEquals([], $result->errors); $result = $this->projectionConnection->fetchAssociative( @@ -1491,7 +1499,7 @@ class { $subscriberRepository, ); - $engine->setup(); + $engine->execute(new SetupCommand()); $subscriptions = $engine->subscriptions(); self::assertCount(1, $subscriptions); @@ -1512,7 +1520,7 @@ class { $newSubscriberRepository, ); - $engine->refresh(); + $engine->execute(new Refresh()); $subscriptions = $engine->subscriptions(); self::assertCount(1, $subscriptions); @@ -1521,6 +1529,13 @@ class { self::assertEquals(RunMode::FromNow, $subscriptions[0]->runMode()); } + /** @phpstan-assert ProcessedResult $result */ + private static function assertProcessedMessages(int $expected, Result $result): void + { + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertSame($expected, $result->processedMessages); + } + /** @param list $subscriptions */ private static function findSubscription(array $subscriptions, string $id): Subscription { diff --git a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php index f871c0d69..cd6bc2133 100644 --- a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php @@ -4,12 +4,10 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; -use LogicException; -use Patchlevel\EventSourcing\Subscription\Engine\CanRefreshSubscriptions; use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; -use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -20,87 +18,17 @@ #[CoversClass(CatchUpSubscriptionEngine::class)] final class CatchUpSubscriptionEngineTest extends TestCase { - public function testSetup(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('setup')->with($criteria, true)->willReturn($expectedResult); - $result = $engine->setup($criteria, true); - - self::assertSame($expectedResult, $result); - } - - public function testBootFinished(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(0); - - $parent->expects($this->exactly(1))->method('boot')->with($criteria, 42)->willReturn($expectedResult); - $result = $engine->boot($criteria, 42); - - self::assertEquals($expectedResult, $result); - } - - public function testBootSecondTime(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $error = new Error( - 'foo', - 'bar', - new RuntimeException('baz'), - ); - - $parent->expects($this->exactly(2))->method('boot')->with($criteria, 42)->willReturn( - new ProcessedResult(1), - new ProcessedResult(0, true, [$error]), - ); - - $result = $engine->boot($criteria, 42); - - self::assertEquals(new ProcessedResult(1, true, [$error]), $result); - } - - public function testBootLimit(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent, 2); - $criteria = new SubscriptionEngineCriteria(); - - $parent->expects($this->exactly(2))->method('boot')->with($criteria, 42)->willReturn( - new ProcessedResult(1), - new ProcessedResult(1), - ); - - $result = $engine->boot($criteria, 42); - - self::assertEquals(new ProcessedResult(2), $result); - } - public function testRunFinished(): void { $parent = $this->createMock(SubscriptionEngine::class); $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); $expectedResult = new ProcessedResult(0); + $command = new Run(); - $parent->expects($this->once())->method('run')->with($criteria, 42)->willReturn($expectedResult); - $result = $engine->run($criteria, 42); + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + $result = $engine->execute($command); self::assertEquals($expectedResult, $result); } @@ -110,7 +38,7 @@ public function testRunSecondTime(): void $parent = $this->createMock(SubscriptionEngine::class); $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); + $command = new Run(); $error = new Error( 'foo', @@ -118,11 +46,11 @@ public function testRunSecondTime(): void new RuntimeException('baz'), ); - $parent->expects($this->exactly(2))->method('run')->with($criteria, 42)->willReturn( + $parent->expects($this->exactly(2))->method('execute')->with($command)->willReturn( new ProcessedResult(1, true, [$error]), new ProcessedResult(0), ); - $result = $engine->run($criteria, 42); + $result = $engine->execute($command); self::assertEquals(new ProcessedResult(1, false, [$error]), $result); } @@ -132,78 +60,18 @@ public function testRunLimit(): void $parent = $this->createMock(SubscriptionEngine::class); $engine = new CatchUpSubscriptionEngine($parent, 2); - $criteria = new SubscriptionEngineCriteria(); + $command = new Run(); - $parent->expects($this->exactly(2))->method('run')->with($criteria, 42)->willReturn( + $parent->expects($this->exactly(2))->method('execute')->with($command)->willReturn( new ProcessedResult(1), new ProcessedResult(1), ); - $result = $engine->run($criteria, 42); + $result = $engine->execute($command); self::assertEquals(new ProcessedResult(2), $result); } - public function testTeardown(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('teardown')->with($criteria)->willReturn($expectedResult); - $result = $engine->teardown($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRemove(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('remove')->with($criteria)->willReturn($expectedResult); - $result = $engine->remove($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testReactivate(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('reactivate')->with($criteria)->willReturn($expectedResult); - $result = $engine->reactivate($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testPause(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('pause')->with($criteria)->willReturn($expectedResult); - $result = $engine->pause($criteria); - - self::assertSame($expectedResult, $result); - } - public function testSubscriptions(): void { $parent = $this->createMock(SubscriptionEngine::class); @@ -218,32 +86,4 @@ public function testSubscriptions(): void self::assertEquals($expectedSubscriptions, $subscriptions); } - - public function testRefreshSubscriptions(): void - { - $parent = $this->createMockForIntersectionOfInterfaces([ - SubscriptionEngine::class, - CanRefreshSubscriptions::class, - ]); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('refresh')->with($criteria)->willReturn($expectedResult); - $result = $engine->refresh($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRefreshSubscriptionsNotSupported(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - - $this->expectException(LogicException::class); - $engine->refresh(); - } } diff --git a/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php deleted file mode 100644 index 45f01aec2..000000000 --- a/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php +++ /dev/null @@ -1,4785 +0,0 @@ -createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with(0, []); - - $store = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $store, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - $store->assertNoChanges(); - self::assertEquals([], $result->errors); - } - - public function testSetupWithoutCreateMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ); - } - - public function testSetupWithCreateMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $created = false; - - #[Setup] - public function create(): void - { - $this->created = true; - } - }; - - $subscriptionStore = new DummySubscriptionStore(); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ); - - self::assertTrue($subscriber->created); - } - - public function testSetupWithCreateError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription($subscriptionId), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::New, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testSetupWithCreateErrorNoRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription($subscriptionId), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::New, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testSetupWithCreateErrorRecoveryNotPossible(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription($subscriptionId), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - null, - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::New, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testSetupWithSkipBooting(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(null, true); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testSetupWithFromNow(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::New, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::Active, - 1, - ), - ); - } - - public function testSetupWithFromNowWithEmptyStream(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::New, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(0); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::Active, - 0, - ), - ); - } - - public function testSetupWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(3)) - ->method('find') - ->willReturnCallback( - new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Error])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::New])], - [], - ], - ]), - ); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->setup($engineCriteria); - } - - public function testNothingToBoot(): void - { - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any(), $this->any()); - - $store = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $store, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $store->assertNoChanges(); - } - - public function testBootDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any(), $this->any()); - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - $subscriptionStore->assertNoUpdated(); - } - - public function testBootWithSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - } - - public function testBootWithError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithErrorNoRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithErrorAndRecovery(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ); - } - - public function testBootWithErrorAndRecoveryFailed(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - throw new RuntimeException('RECOVERY ERROR'); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithErrorAndRecoveryFailedBecauseBatching(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class implements BatchableSubscriber { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - - public function beginBatch(): void - { - // TODO: Implement beginBatch() method. - } - - public function commitBatch(): void - { - // TODO: Implement commitBatch() method. - } - - public function rollbackBatch(): void - { - // TODO: Implement rollbackBatch() method. - } - - public function forceCommit(): bool - { - return false; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithLimit(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(new SubscriptionEngineCriteria(), 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - } - - public function testBootingWithSkip(): void - { - $subscriptionId1 = 'test1'; - $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionId2 = 'test2'; - $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber1->message); - self::assertNull($subscriber2->message); - } - - public function testBootingWithGabInIndex(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @var list */ - public array $messages = []; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->messages[] = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 3 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 3, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->messages); - } - - public function testBootingWithOnlyOnce(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::Once)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Finished, - 1, - ), - ); - - self::assertEquals($message1, $subscriber->message); - } - - public function testBootAlreadyProcessing(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public SubscriptionEngine|null $engine = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(): void - { - $this->engine?->boot(); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $subscriber->engine = $engine; - - $result = $engine->boot(); - - self::assertCount(1, $result->errors); - self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); - } - - public function testBootTwice(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->exactly(2)) - ->method('load') - ->willReturnCallback(new ReturnCallback([ - [ - [0, [$subscription]], - new Stream([1 => $message]), - ], - [ - [1, [$subscription]], - new Stream([]), - ], - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(limit: 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - - $subscriptionStore->reset(); - $result = $engine->boot(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - } - - public function testBootWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testBootBatchingSuccess(): void - { - $subscriber = new BatchingSubscriber(); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingSuccessForceCommit(): void - { - $subscriber = new BatchingSubscriber( - forceCommitAfterMessages: 1, - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 2 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 2, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->receivedMessages); - self::assertSame(2, $subscriber->beginBatchCalled); - self::assertSame(2, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithHandleError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithBeginBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForBeginBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), - ), - ), - ); - - self::assertSame([], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithCommitBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForCommitBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithRollbackBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - throwForRollbackBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testBootWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(3)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Error])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Booting])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->boot($engineCriteria); - } - - public function testRunDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testRunning(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - } - - public function testRunningWithLimit(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0) - ->willReturn(new Stream([1 => $message1, 2 => $message2])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(new SubscriptionEngineCriteria(), 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message1, $subscriber->message); - } - - public function testRunningWithSkip(): void - { - $subscriptionId1 = 'test1'; - $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionId2 = 'test2'; - $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber1->message); - self::assertNull($subscriber2->message); - } - - public function testRunningWithError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testRunningWithErrorNoRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testRunningWithErrorAndRecovery(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - } - - public function testRunningWithErrorAndRecoveryFailed(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - throw new RuntimeException('RECOVERY ERROR'); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testRunningMarkDetached(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - 0, - ), - ); - } - - public function testRunningWithoutActiveSubscribers(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testRunningWithGabInIndex(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @var list */ - public array $messages = []; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->messages[] = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 3 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 3, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->messages); - } - - public function testRunningWithOnlyOnce(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::Once)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Finished, - 1, - ), - ); - - self::assertEquals($message1, $subscriber->message); - } - - public function testRunningAlreadyProcessing(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public SubscriptionEngine|null $engine = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(): void - { - $this->engine?->run(); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $subscriber->engine = $engine; - - $result = $engine->run(); - - self::assertCount(1, $result->errors); - self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); - } - - public function testRunningTwice(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->exactly(2)) - ->method('load') - ->willReturnCallback(new ReturnCallback([ - [ - [0, [$subscription]], - new Stream([1 => $message]), - ], - [ - [1, [$subscription]], - new Stream([]), - ], - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(limit: 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - - $subscriptionStore->reset(); - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testRunningBatchingSuccess(): void - { - $subscriber = new BatchingSubscriber(); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingSuccessForceCommit(): void - { - $subscriber = new BatchingSubscriber( - forceCommitAfterMessages: 1, - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 2 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 2, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->receivedMessages); - self::assertSame(2, $subscriber->beginBatchCalled); - self::assertSame(2, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithHandleError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithBeginBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForBeginBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), - ), - ), - ); - - self::assertSame([], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithCommitBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForCommitBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithRollbackBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - throwForRollbackBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testRunWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(4)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Active, Status::Paused, Status::Finished])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Error])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Active])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->run($engineCriteria); - } - - public function testTeardownDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testTeardownWithoutTeardownMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testTeardownWithSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - self::assertTrue($subscriber->dropped); - } - - public function testTeardownWithSubscriberAndError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - throw new RuntimeException('ERROR'); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertNoChanges(); - } - - public function testTeardownWithoutSubscriber(): void - { - $subscriberId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriberId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testTeardownWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Detached])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->teardown($engineCriteria); - } - - public function testTeardownWithCleanupAndWithoutCleaner(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [ - new DropTableTask('test'), - ], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $this->expectException(CleanerNotConfigured::class); - - $engine->teardown(); - } - - public function testTeardownWithCleanupAndSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testTeardownWithCleanupAndWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testTeardownWithCleanupHandlerError(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ), - ]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->teardown(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertInstanceOf(CleanupFailed::class, $error->throwable); - - $subscriptionStore->assertNoChanges(); - } - - public function testRemoveDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testRemoveWithSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - self::assertTrue($subscriber->dropped); - } - - public function testRemoveWithoutDropMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithSubscriberAndError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - throw new RuntimeException('ERROR'); - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveNewSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - self::assertFalse($subscriber->dropped); - } - - public function testRemoveWithoutSubscriber(): void - { - $subscriberId = 'test'; - - $subscription = new Subscription( - $subscriberId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->remove($engineCriteria); - } - - public function testRemoveWithCleanupAndWithoutCleaner(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [ - new DropTableTask('test'), - ], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $this->expectException(CleanerNotConfigured::class); - - $engine->remove(); - } - - public function testRemoveWithCleanupAndSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithCleanupAndWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithCleanupHandlerError(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->remove(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertInstanceOf(CleanupFailed::class, $error->throwable); - - $subscriptionStore->assertRemoved($subscription); - } - - public function testReactiveDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testReactivateError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::New), - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - 0, - ), - ); - } - - public function testReactivateDetached(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testReactivatePaused(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testReactivateFinished(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Finished, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testReactivateWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [ - new SubscriptionCriteria( - ['id1'], - ['group1'], - [ - Status::Error, - Status::Failed, - Status::Detached, - Status::Paused, - Status::Finished, - ], - ), - ], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->reactivate($engineCriteria); - } - - public function testPauseDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testPauseBooting(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ); - } - - public function testPauseActive(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ); - } - - public function testPauseError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::New), - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - 0, - new SubscriptionError('ERROR', Status::New), - ), - ); - } - - public function testPauseWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ); - } - - public function testPauseWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Active, Status::Booting, Status::Error])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->pause($engineCriteria); - } - - public function testGetSubscriptionAndDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $subscriptions = $engine->subscriptions(); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - self::assertCount(1, $subscriptions); - $subscription = $subscriptions[0]; - - self::assertEquals($subscriptionId, $subscription->id()); - self::assertEquals(Subscription::DEFAULT_GROUP, $subscription->group()); - self::assertEquals(RunMode::FromBeginning, $subscription->runMode()); - self::assertEquals(Status::New, $subscription->status()); - } - - public function testRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - #[Subscribe(ProfileVisited::class)] - public function subscribe(): void - { - throw new RuntimeException('ERROR2'); - } - }; - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::Active), - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $retryStrategy = $this->createMock(RetryStrategy::class); - $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(true); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - new RetryStrategyRepository([RetryStrategyRepository::DEFAULT_STRATEGY_NAME => $retryStrategy]), - new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR2', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - self::assertCount(2, $subscriptionStore->updatedSubscriptions); - - [$update1, $update2] = $subscriptionStore->updatedSubscriptions; - - self::assertEquals($subscriptionId, $update1->id()); - self::assertEquals(Subscription::DEFAULT_GROUP, $update1->group()); - self::assertEquals(RunMode::FromBeginning, $update1->runMode()); - self::assertEquals(Status::Active, $update1->status()); - self::assertEquals(0, $update1->position()); - self::assertNull($update1->subscriptionError()); - self::assertEquals(1, $update1->retryAttempt()); - - self::assertEquals(Status::Error, $update2->status()); - self::assertEquals(Status::Active, $update2->subscriptionError()?->previousStatus); - self::assertEquals('ERROR2', $update2->subscriptionError()?->errorMessage); - self::assertEquals(1, $update2->retryAttempt()); - } - - #[DataProvider('statusProvider')] - public function testShouldNotRetryOtherStatus(string $method, string $status): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::from($status)), - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $retryStrategy = $this->createMock(RetryStrategy::class); - $retryStrategy->expects($this->never())->method('shouldRetry')->with($subscription); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - new RetryStrategyRepository([RetryStrategyRepository::DEFAULT_STRATEGY_NAME => $retryStrategy]), - new NullLogger(), - ); - - $result = match ($method) { - 'setup' => $engine->setup(), - 'boot' => $engine->boot(), - 'run' => $engine->run(), - }; - - self::assertCount(0, $result->errors); - $subscriptionStore->assertNoChanges(); - } - - public static function statusProvider(): Generator - { - yield 'setup_booting' => ['setup', 'booting']; - yield 'setup_active' => ['setup', 'active']; - yield 'boot_new' => ['boot', 'new']; - yield 'boot_active' => ['boot', 'active']; - yield 'run_new' => ['run', 'new']; - yield 'run_booting' => ['run', 'booting']; - } - - public function testShouldNotRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::Active), - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $retryStrategy = $this->createMock(RetryStrategy::class); - $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(false); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - new RetryStrategyRepository([RetryStrategyRepository::DEFAULT_STRATEGY_NAME => $retryStrategy]), - new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertCount(0, $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testDontLockGetSubscriptions(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromNow)] - class { - }; - - $subscriptionStore = $this->createMock(LockableSubscriptionStore::class); - $subscriptionStore - ->expects($this->never()) - ->method('inLock'); - - $subscriptionStore - ->expects($this->exactly(2)) - ->method('find') - ->with(new SubscriptionCriteria()) - ->willReturn([new Subscription('id1')]); - - $subscriptionStore - ->expects($this->never()) - ->method('remove') - ->with($this->isInstanceOf(Subscription::class)); - - $subscriptionStore - ->expects($this->never()) - ->method('add') - ->with($this->isInstanceOf(Subscription::class)); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load'); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engine->subscriptions(); - } - - public function testFromNowWithoutSetupDirectActive(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::Active, - 1, - ), - ); - } - - public function testRefreshSubscriptionsNoChanges(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'default')] - class { - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertNoChanges(); - } - - public function testRefreshSubscriptionsChangeRunMode(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'default', - RunMode::FromNow, - Status::Active, - ), - ); - } - - public function testRefreshSubscriptionsChangeGroup(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'new-group')] - class { - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'new-group', - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testRefreshSubscriptionsChangeCleanupTasks(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - yield new DropTableTask('test'); - } - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - cleanupTasks: [new DropTableTask('test')], - ), - ); - } - - public function testRefreshSubscriptionsMultipleChanges(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromNow, group: 'new-group')] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - yield new DropTableTask('test'); - } - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'new-group', - RunMode::FromNow, - Status::Active, - cleanupTasks: [new DropTableTask('test')], - ), - ); - } - - public function testRefreshSubscriptionsWithCriteria(): void - { - $subscriber1 = new #[Subscriber('test1', RunMode::FromNow)] - class { - }; - - $subscriber2 = new #[Subscriber('test2', RunMode::FromNow)] - class { - }; - - $subscription1 = new Subscription( - 'test1', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscription2 = new Subscription( - 'test2', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription1, $subscription2]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(new SubscriptionEngineCriteria(['test1'])); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test1', - 'default', - RunMode::FromNow, - Status::Active, - ), - ); - - self::assertCount(1, $subscriptionStore->updatedSubscriptions); - } - - public function testRefreshSubscriptionsDiscoverNewSubscribers(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertAdded( - new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::New, - ), - ); - } -} diff --git a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php new file mode 100644 index 000000000..2a9987e35 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php @@ -0,0 +1,592 @@ + $subscribers */ + private function createHandler( + MessageLoader $messageLoader, + DummySubscriptionStore $store, + array $subscribers = [], + RetryStrategyRepository|null $retryStrategyRepository = null, + ): BootHandler { + $retryStrategyRepository ??= new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + return new BootHandler($messageLoader, $subscriptionManager, $subscriberRepository, $messageProcessor, $eventDispatcher, new NullLogger()); + } + + public function testNothingToBoot(): void + { + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + $store = new DummySubscriptionStore(); + $handler = $this->createHandler($messageLoader, $store); + + $result = $handler(new BootCommand()); + + self::assertEquals(0, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoChanges(); + } + + public function testBootWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber->message); + } + + public function testBootWithError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithErrorNoRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithErrorAndRecovery(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting, 1), + ); + } + + public function testBootWithErrorAndRecoveryFailed(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + throw new RuntimeException('RECOVERY ERROR'); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithErrorAndRecoveryFailedBecauseBatching(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class implements BatchableSubscriber { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + + public function beginBatch(): void + { + } + + public function commitBatch(): void + { + } + + public function rollbackBatch(): void + { + } + + public function forceCommit(): bool + { + return false; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithLimit(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand(limit: 1)); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(false, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting, 1), + ); + + self::assertSame($message, $subscriber->message); + } + + public function testBootingWithSkip(): void + { + $subscriptionId1 = 'test1'; + $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionId2 = 'test2'; + $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting, 1), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber1, $subscriber2]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber1->message); + self::assertNull($subscriber2->message); + } + + public function testBootingWithGabInIndex(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + /** @var list */ + public array $messages = []; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->messages[] = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 3 => $message2, + ])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 3), + ); + + self::assertSame([$message1, $message2], $subscriber->messages); + } + + public function testBootingWithOnlyOnce(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::Once)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Finished, 1), + ); + + self::assertEquals($message, $subscriber->message); + } + + public function testBootWithoutSubscriber(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoChanges(); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php new file mode 100644 index 000000000..71e48c997 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php @@ -0,0 +1,94 @@ +createHandler($store); + $result = $handler(new PauseCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused), + ); + } + + public function testPauseActive(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new PauseCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused), + ); + } + + public function testPauseError(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new PauseCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Paused, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php new file mode 100644 index 000000000..3b5ebb3f0 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php @@ -0,0 +1,124 @@ + $subscribers */ + private function createHandler(DummySubscriptionStore $store, array $subscribers = []): ReactivateHandler + { + return new ReactivateHandler( + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + new NullLogger(), + ); + } + + public function testReactivateError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New, 0), + ); + } + + public function testReactivateDetached(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } + + public function testReactivatePaused(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } + + public function testReactivateFinished(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Finished), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php new file mode 100644 index 000000000..038ca2dee --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php @@ -0,0 +1,129 @@ + $subscribers */ + private function createHandler(DummySubscriptionStore $store, array $subscribers = []): RefreshHandler + { + return new RefreshHandler( + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + new NullLogger(), + ); + } + + public function testRefreshSubscriptionsNoChanges(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'default')] + class { + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertNoChanges(); + } + + public function testRefreshSubscriptionsChangeRunMode(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromNow)] + class { + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'default', RunMode::FromNow, Status::Active), + ); + } + + public function testRefreshSubscriptionsChangeGroup(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'new-group')] + class { + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'new-group', RunMode::FromBeginning, Status::Active), + ); + } + + public function testRefreshSubscriptionsChangeCleanupTasks(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + /** @return iterable */ + #[Cleanup] + public function cleanup(): iterable + { + yield new DropTableTask('test'); + } + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'default', RunMode::FromBeginning, Status::Active, cleanupTasks: [new DropTableTask('test')]), + ); + } + + public function testRefreshSubscriptionsMultipleChanges(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromNow, group: 'new-group')] + class { + /** @return iterable */ + #[Cleanup] + public function cleanup(): iterable + { + yield new DropTableTask('test'); + } + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'new-group', RunMode::FromNow, Status::Active, cleanupTasks: [new DropTableTask('test')]), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php new file mode 100644 index 000000000..f70eb96d0 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php @@ -0,0 +1,255 @@ + $subscribers */ + private function createHandler( + DummySubscriptionStore $store, + array $subscribers = [], + CleanupRunner|null $cleanupRunner = null, + ): RemoveHandler { + $subscriptionManager = new SubscriptionManager($store); + + return new RemoveHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository($subscribers), + $cleanupRunner ?? new CleanupRunner($subscriptionManager, null, new NullLogger()), + new NullLogger(), + ); + } + + public function testRemoveWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + $this->dropped = true; + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + self::assertTrue($subscriber->dropped); + } + + public function testRemoveWithoutDropMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveWithSubscriberAndError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + #[Teardown] + public function drop(): void + { + throw new RuntimeException('ERROR'); + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveNewSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + $this->dropped = true; + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + self::assertFalse($subscriber->dropped); + } + + public function testRemoveWithoutSubscriber(): void + { + $subscriberId = 'test'; + + $subscription = new Subscription($subscriberId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveWithCleanupAndWithoutCleaner(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [new DropTableTask('test')], + ); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + + $this->expectException(CleanerNotConfigured::class); + $handler(new RemoveCommand()); + } + + public function testRemoveWithCleanupAndSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new RemoveHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([$subscriber]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveWithCleanupHandlerError(): void + { + $subscriptionId = 'test'; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new RemoveHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new RemoveCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertInstanceOf(CleanupFailed::class, $error->throwable); + + $store->assertRemoved($subscription); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php new file mode 100644 index 000000000..f7dd5b4e7 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php @@ -0,0 +1,545 @@ + $subscribers + * + * @return array{RunHandler, EventDispatcher, RunCommand} + */ + private function createHandler( + MessageLoader $messageLoader, + DummySubscriptionStore $store, + array $subscribers = [], + RetryStrategyRepository|null $retryStrategyRepository = null, + ): array { + $retryStrategyRepository ??= new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addListener(OnCommand::class, new DetachListener($subscriptionManager, $subscriberRepository, new NullLogger()), 32); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + $handler = new RunHandler($messageLoader, $subscriptionManager, $messageProcessor, $eventDispatcher, new NullLogger()); + + return [$handler, $eventDispatcher, new RunCommand()]; + } + + public function testRunning(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber->message); + } + + public function testRunningWithLimit(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null) + ->willReturn(new Stream([1 => $message1, 2 => $message2])); + + $command = new RunCommand(limit: 1); + [$handler, $eventDispatcher] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(false, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message1, $subscriber->message); + } + + public function testRunningWithSkip(): void + { + $subscriptionId1 = 'test1'; + $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionId2 = 'test2'; + $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber1, $subscriber2]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber1->message); + self::assertNull($subscriber2->message); + } + + public function testRunningWithError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testRunningWithErrorNoRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testRunningWithErrorAndRecovery(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + } + + public function testRunningWithErrorAndRecoveryFailed(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + throw new RuntimeException('RECOVERY ERROR'); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testRunningMarkDetached(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + // DetachListener fires on OnCommand and marks the subscription as Detached + // before the handler processes it (no subscriber registered) + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(0, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached, 0), + ); + } + + public function testRunningWithoutActiveSubscribers(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(0, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoChanges(); + } + + public function testRunningWithGabInIndex(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + /** @var list */ + public array $messages = []; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->messages[] = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 3 => $message2, + ])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 3), + ); + + self::assertSame([$message1, $message2], $subscriber->messages); + } + + public function testRunningWithOnlyOnce(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::Once)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Finished, 1), + ); + + self::assertEquals($message, $subscriber->message); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php new file mode 100644 index 000000000..8a59e3cf1 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php @@ -0,0 +1,354 @@ + $subscribers */ + private function createHandler( + MessageLoader $messageLoader, + DummySubscriptionStore $store, + array $subscribers = [], + RetryStrategyRepository|null $retryStrategyRepository = null, + ): SetupHandler { + return new SetupHandler( + $messageLoader, + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + $retryStrategyRepository ?? new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]), + new NullLogger(), + ); + } + + public function testNothingToSetup(): void + { + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + $store = new DummySubscriptionStore(); + $handler = $this->createHandler($messageLoader, $store); + + $result = $handler(new SetupCommand()); + + $store->assertNoChanges(); + self::assertEquals([], $result->errors); + } + + public function testSetupWithoutCreateMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ); + } + + public function testSetupWithCreateMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $created = false; + + #[Setup] + public function create(): void + { + $this->created = true; + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ); + + self::assertTrue($subscriber->created); + } + + public function testSetupWithCreateError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Setup] + public function create(): void + { + throw $this->exception; + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ); + } + + public function testSetupWithCreateErrorNoRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Setup] + public function create(): void + { + throw $this->exception; + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ); + } + + public function testSetupWithCreateErrorRecoveryNotPossible(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Setup] + public function create(): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ); + } + + public function testSetupWithSkipBooting(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand(skipBooting: true)); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } + + public function testSetupWithFromNow(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromNow)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::Active, 1), + ); + } + + public function testSetupWithFromNowWithEmptyStream(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromNow)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(0); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::Active, 0), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php new file mode 100644 index 000000000..880864f26 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php @@ -0,0 +1,263 @@ + $subscribers */ + private function createHandler( + DummySubscriptionStore $store, + array $subscribers = [], + CleanupRunner|null $cleanupRunner = null, + ): TeardownHandler { + $subscriptionManager = new SubscriptionManager($store); + + return new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository($subscribers), + $cleanupRunner ?? new CleanupRunner($subscriptionManager, null, new NullLogger()), + new NullLogger(), + ); + } + + public function testTeardownWithoutTeardownMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testTeardownWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + $this->dropped = true; + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + self::assertTrue($subscriber->dropped); + } + + public function testTeardownWithSubscriberAndError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + #[Teardown] + public function drop(): void + { + throw new RuntimeException('ERROR'); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new TeardownCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertNoChanges(); + } + + public function testTeardownWithoutSubscriber(): void + { + $subscriberId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriberId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoChanges(); + } + + public function testTeardownWithCleanupAndWithoutCleaner(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [new DropTableTask('test')], + ); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + + $this->expectException(CleanerNotConfigured::class); + $handler(new TeardownCommand()); + } + + public function testTeardownWithCleanupAndSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([$subscriber]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testTeardownWithCleanupAndWithoutSubscriber(): void + { + $subscriptionId = 'test'; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testTeardownWithCleanupHandlerError(): void + { + $subscriptionId = 'test'; + + $task = new DropTableTask('test'); + $store = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ), + ]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new TeardownCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertInstanceOf(CleanupFailed::class, $error->throwable); + + $store->assertNoChanges(); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php new file mode 100644 index 000000000..2082c6841 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php @@ -0,0 +1,717 @@ + $subscribers */ + private function createBootHandler( + MessageLoader $messageLoader, + DummySubscriptionStore $store, + array $subscribers, + ): BootHandler { + $retryStrategyRepository = new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new BatchSubscriber($subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + return new BootHandler($messageLoader, $subscriptionManager, $subscriberRepository, $messageProcessor, $eventDispatcher, new NullLogger()); + } + + /** + * @param list $subscribers + * + * @return array{RunHandler, EventDispatcher, RunCommand} + */ + private function createRunHandler( + MessageLoader $messageLoader, + DummySubscriptionStore $store, + array $subscribers, + ): array { + $retryStrategyRepository = new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new BatchSubscriber($subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addListener(OnCommand::class, new DetachListener($subscriptionManager, $subscriberRepository, new NullLogger()), 32); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + $handler = new RunHandler($messageLoader, $subscriptionManager, $messageProcessor, $eventDispatcher, new NullLogger()); + + return [$handler, $eventDispatcher, new RunCommand()]; + } + + public function testBootBatchingSuccess(): void + { + $subscriber = new BatchingSubscriber(); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingSuccessForceCommit(): void + { + $subscriber = new BatchingSubscriber( + forceCommitAfterMessages: 1, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 2 => $message2, + ])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 2, + ), + ); + + self::assertSame([$message1, $message2], $subscriber->receivedMessages); + self::assertSame(2, $subscriber->beginBatchCalled); + self::assertSame(2, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithHandleError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForMessage: $exception, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithBeginBatchError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForBeginBatch: $exception, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithCommitBatchError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForCommitBatch: $exception, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithRollbackBatchError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForMessage: $exception, + throwForRollbackBatch: new RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingSuccess(): void + { + $subscriber = new BatchingSubscriber(); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingSuccessForceCommit(): void + { + $subscriber = new BatchingSubscriber( + forceCommitAfterMessages: 1, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 2 => $message2, + ])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 2, + ), + ); + + self::assertSame([$message1, $message2], $subscriber->receivedMessages); + self::assertSame(2, $subscriber->beginBatchCalled); + self::assertSame(2, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithHandleError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForMessage: $exception, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithBeginBatchError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForBeginBatch: $exception, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithCommitBatchError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForCommitBatch: $exception, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithRollbackBatchError(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new BatchingSubscriber( + throwForMessage: $exception, + throwForRollbackBatch: new RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($exception), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php new file mode 100644 index 000000000..dcd86abc2 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php @@ -0,0 +1,88 @@ + $subscribers */ + private function createListener(DummySubscriptionStore $store, array $subscribers = []): DetachListener + { + return new DetachListener( + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + new NullLogger(), + ); + } + + public function testDetachesActiveSubscriptionWithoutSubscriber(): void + { + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $listener = $this->createListener($store); + $listener(new OnCommand(new Run())); + + $store->assertUpdated( + new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ); + } + + public function testDoesNotDetachWhenSubscriberExists(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $listener = $this->createListener($store, [$subscriber]); + $listener(new OnCommand(new Run())); + + $store->assertNoChanges(); + } + + public function testIgnoresNonRunCommands(): void + { + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $listener = $this->createListener($store); + $listener(new OnCommand(new Boot())); + + $store->assertNoChanges(); + } + + public function testDetachesPausedAndFinishedSubscriptions(): void + { + $paused = new Subscription('paused', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused); + $finished = new Subscription('finished', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Finished); + $store = new DummySubscriptionStore([$paused, $finished]); + + $listener = $this->createListener($store); + $listener(new OnCommand(new Run())); + + $store->assertUpdated( + new Subscription('paused', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + new Subscription('finished', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php new file mode 100644 index 000000000..38d4874b6 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php @@ -0,0 +1,229 @@ + $subscribers */ + private function createListener(DummySubscriptionStore $store, array $subscribers = []): DiscoverSubscriber + { + return new DiscoverSubscriber( + $this->createMock(MessageLoader::class), + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + new NullLogger(), + ); + } + + public function testBootDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Boot())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testRunDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Run())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testTeardownDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Teardown())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testRemoveDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Remove())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testReactiveDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Reactivate())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testPauseDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Pause())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testGetSubscriptionAndDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onSubscriptions(new OnSubscriptions(new SubscriptionEngineCriteria())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testDontLockGetSubscriptions(): void + { + $subscriber = new #[Subscriber('id1', RunMode::FromNow)] + class { + }; + + $subscriptionStore = $this->createMock(LockableSubscriptionStore::class); + $subscriptionStore + ->expects($this->never()) + ->method('inLock'); + + $subscriptionStore + ->expects($this->once()) + ->method('find') + ->with(new SubscriptionCriteria()) + ->willReturn([new Subscription('id1')]); + + $subscriptionStore + ->expects($this->never()) + ->method('add'); + + $listener = new DiscoverSubscriber( + $this->createMock(MessageLoader::class), + new SubscriptionManager($subscriptionStore), + new MetadataSubscriberAccessorRepository([$subscriber]), + new NullLogger(), + ); + + $listener->onSubscriptions(new OnSubscriptions(new SubscriptionEngineCriteria())); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php new file mode 100644 index 000000000..0cca7387f --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php @@ -0,0 +1,275 @@ + $subscribers + * + * @return array{FailSubscriber, SubscriptionManager} + */ + private function createListener(DummySubscriptionStore $store, array $subscribers = []): array + { + $subscriptionManager = new SubscriptionManager($store); + + return [ + new FailSubscriber( + $subscriptionManager, + new MetadataSubscriberAccessorRepository($subscribers), + new NullLogger(), + ), + $subscriptionManager, + ]; + } + + public function testDoesNothingWhenTransitionToFailedIsFalse(): void + { + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + new RuntimeException('ERROR'), + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: false, + )); + $subscriptionManager->flush(); + + $store->assertNoChanges(); + } + + public function testFailsSubscriptionWhenNoSubscriberFound(): void + { + $exception = new RuntimeException('ERROR'); + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } + + public function testFailsSubscriptionForBatchableSubscriber(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class implements BatchableSubscriber { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + + #[OnFailed] + public function onFailed(): void + { + } + + public function beginBatch(): void + { + } + + public function commitBatch(): void + { + } + + public function rollbackBatch(): void + { + } + + public function forceCommit(): bool + { + return false; + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } + + public function testFailsSubscriptionWithoutFailedMethod(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } + + public function testRecoverySucceeds(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + } + + public function testRecoveryFails(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + + #[OnFailed] + public function onFailed(): void + { + throw new RuntimeException('RECOVERY ERROR'); + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php new file mode 100644 index 000000000..edb3dceb2 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -0,0 +1,150 @@ + $subscribers */ + private function createListener( + DummySubscriptionStore $store, + array $subscribers, + RetryStrategy $retryStrategy, + ): RetrySubscriber { + return new RetrySubscriber( + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + RetryStrategyRepository::withDefault($retryStrategy), + new NullLogger(), + ); + } + + public function testRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active), + ); + + $store = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->createMock(RetryStrategy::class); + $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(true); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + $listener->onCommand(new OnCommand(new Run())); + + self::assertCount(1, $store->updatedSubscriptions); + + $updated = $store->updatedSubscriptions[0]; + self::assertEquals($subscriptionId, $updated->id()); + self::assertEquals(Status::Active, $updated->status()); + self::assertEquals(1, $updated->retryAttempt()); + self::assertNull($updated->subscriptionError()); + } + + /** @param 'setup'|'boot'|'run' $method */ + #[DataProvider('statusProvider')] + public function testShouldNotRetryOtherStatus(string $method, string $status): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::from($status)), + ); + + $store = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->createMock(RetryStrategy::class); + $retryStrategy->expects($this->never())->method('shouldRetry'); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + + $command = match ($method) { + 'setup' => new Setup(), + 'boot' => new Boot(), + 'run' => new Run(), + }; + + $listener->onCommand(new OnCommand($command)); + + $store->assertNoChanges(); + } + + public static function statusProvider(): Generator + { + yield 'setup_booting' => ['setup', 'booting']; + yield 'setup_active' => ['setup', 'active']; + yield 'boot_new' => ['boot', 'new']; + yield 'boot_active' => ['boot', 'active']; + yield 'run_new' => ['run', 'new']; + yield 'run_booting' => ['run', 'booting']; + } + + public function testShouldNotRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active), + ); + + $store = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->createMock(RetryStrategy::class); + $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(false); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + $listener->onCommand(new OnCommand(new Run())); + + $store->assertNoChanges(); + } +} diff --git a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php new file mode 100644 index 000000000..625bfc83a --- /dev/null +++ b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php @@ -0,0 +1,109 @@ +engine?->execute(new Boot()); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn( + new Stream([1 => new Message(new ProfileVisited(ProfileId::fromString('test')))]), + ); + + $engine = new DefaultSubscriptionEngine( + $messageLoader, + $store, + new MetadataSubscriberAccessorRepository([$subscriber]), + logger: new NullLogger(), + ); + + $subscriber->engine = $engine; + + $result = $engine->execute(new Boot()); + + self::assertCount(1, $result->errors); + self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); + } + + public function testAlreadyProcessingOnRun(): void + { + $subscriptionId = 'test'; + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public DefaultSubscriptionEngine|null $engine = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + $this->engine?->execute(new Run()); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn( + new Stream([1 => new Message(new ProfileVisited(ProfileId::fromString('test')))]), + ); + + $engine = new DefaultSubscriptionEngine( + $messageLoader, + $store, + new MetadataSubscriberAccessorRepository([$subscriber]), + logger: new NullLogger(), + ); + + $subscriber->engine = $engine; + + $result = $engine->execute(new Run()); + + self::assertCount(1, $result->errors); + self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); + } +} diff --git a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php index 50d888f7c..b42a6b616 100644 --- a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php @@ -4,12 +4,10 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; -use LogicException; -use Patchlevel\EventSourcing\Subscription\Engine\CanRefreshSubscriptions; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\ErrorDetected; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; -use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; @@ -20,235 +18,39 @@ #[CoversClass(ThrowOnErrorSubscriptionEngine::class)] final class ThrowOnErrorSubscriptionEngineTest extends TestCase { - public function testSetupSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('setup')->with($criteria, true)->willReturn($expectedResult); - $result = $engine->setup($criteria, true); - - self::assertSame($expectedResult, $result); - } - - public function testSetupError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('setup')->with($criteria, false)->willReturn($expectedResult); - $engine->setup($criteria); - } - - public function testBootSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(5); - - $parent->expects($this->once())->method('boot')->with($criteria, 10)->willReturn($expectedResult); - $result = $engine->boot($criteria, 10); - - self::assertSame($expectedResult, $result); - } - - public function testBootError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(5, false, [ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('boot')->with($criteria, 10)->willReturn($expectedResult); - $engine->boot($criteria, 10); - } - public function testRunSuccess(): void { $parent = $this->createMock(SubscriptionEngine::class); $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); $expectedResult = new ProcessedResult(5); - $parent->expects($this->once())->method('run')->with($criteria, 10)->willReturn($expectedResult); - $result = $engine->run($criteria, 10); - - self::assertSame($expectedResult, $result); - } - - public function testRunError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(5, false, [ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); + $command = new Setup(); - $parent->expects($this->once())->method('run')->with($criteria, 10)->willReturn($expectedResult); - $engine->run($criteria, 10); - } - - public function testTeardownSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('teardown')->with($criteria)->willReturn($expectedResult); - $result = $engine->teardown($criteria); + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + $result = $engine->execute($command); self::assertSame($expectedResult, $result); } - public function testTeardownError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('teardown')->with($criteria)->willReturn($expectedResult); - $engine->teardown($criteria); - } - - public function testRemoveSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('remove')->with($criteria)->willReturn($expectedResult); - $result = $engine->remove($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRemoveError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('remove')->with($criteria)->willReturn($expectedResult); - $engine->remove($criteria); - } - - public function testReactivateSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('reactivate')->with($criteria)->willReturn($expectedResult); - $result = $engine->reactivate($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testReactivateError(): void + public function testRunError(): void { $this->expectException(ErrorDetected::class); $parent = $this->createMock(SubscriptionEngine::class); $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - $parent->expects($this->once())->method('reactivate')->with($criteria)->willReturn($expectedResult); - $engine->reactivate($criteria); - } + $command = new Setup(); - public function testPauseSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('pause')->with($criteria)->willReturn($expectedResult); - $result = $engine->pause($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testPauseError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ + $expectedResult = new ProcessedResult(5, false, [ new Error('id1', 'error1', new RuntimeException('error1')), new Error('id2', 'error2', new RuntimeException('error2')), ]); - $parent->expects($this->once())->method('pause')->with($criteria)->willReturn($expectedResult); - $engine->pause($criteria); + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + $engine->execute($command); } public function testSubscriptions(): void @@ -263,52 +65,4 @@ public function testSubscriptions(): void self::assertSame([], $result); } - - public function testRefreshSubscriptionsSuccess(): void - { - $parent = $this->createMockForIntersectionOfInterfaces([ - SubscriptionEngine::class, - CanRefreshSubscriptions::class, - ]); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('refresh')->with($criteria)->willReturn($expectedResult); - $result = $engine->refresh($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRefreshSubscriptionsError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMockForIntersectionOfInterfaces([ - SubscriptionEngine::class, - CanRefreshSubscriptions::class, - ]); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - ]); - - $parent->expects($this->once())->method('refresh')->with($criteria)->willReturn($expectedResult); - $engine->refresh($criteria); - } - - public function testRefreshSubscriptionsNotSupported(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - - $this->expectException(LogicException::class); - $engine->refresh(); - } } diff --git a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php index 77f025c16..8ab58ff7a 100644 --- a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php +++ b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php @@ -6,9 +6,9 @@ use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Subscription\Engine\AlreadyProcessing; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepository; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile; @@ -66,9 +66,10 @@ public function testHas(): void public function testSave(): void { - $criteria = new SubscriptionEngineCriteria( + $command = new Run( ['id1', 'id2'], ['group1', 'group2'], + 42, ); $aggregate = Profile::createProfile( @@ -80,7 +81,7 @@ public function testSave(): void $defaultRepository->expects($this->once())->method('save')->with($aggregate); $engine = $this->createMock(SubscriptionEngine::class); - $engine->expects($this->once())->method('run')->with($criteria, 42)->willReturn(new ProcessedResult(21)); + $engine->expects($this->once())->method('execute')->with($command)->willReturn(new ProcessedResult(21)); $repository = new RunSubscriptionEngineRepository( $defaultRepository, @@ -95,9 +96,10 @@ public function testSave(): void public function testSaveWithAlreadyProcessing(): void { - $criteria = new SubscriptionEngineCriteria( + $command = new Run( ['id1', 'id2'], ['group1', 'group2'], + 42, ); $aggregate = Profile::createProfile( @@ -109,7 +111,7 @@ public function testSaveWithAlreadyProcessing(): void $defaultRepository->expects($this->once())->method('save')->with($aggregate); $engine = $this->createMock(SubscriptionEngine::class); - $engine->expects($this->once())->method('run')->with($criteria, 42)->willThrowException(new AlreadyProcessing()); + $engine->expects($this->once())->method('execute')->with($command)->willThrowException(new AlreadyProcessing()); $repository = new RunSubscriptionEngineRepository( $defaultRepository,