diff --git a/baseline.xml b/baseline.xml index e107ec3e1..617141b91 100644 --- a/baseline.xml +++ b/baseline.xml @@ -20,11 +20,6 @@ getName()]]> - - - - - @@ -44,29 +39,6 @@ ]]> - - - - - - - - - errorContext]]> - errorContext]]> - - - - - - - - - - projector->$method(...)]]> - - - @@ -93,21 +65,35 @@ ]]> + + + + + + + + + errorContext]]> + errorContext]]> + + + + + + + + + + subscriber->$method(...)]]> + + + - - - - - - - - - @@ -137,6 +123,15 @@ + + + + + + + + + @@ -150,12 +145,6 @@ - - - - - - @@ -163,13 +152,13 @@ - + - + @@ -247,7 +236,7 @@ - + diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 20e39c784..ebe9a619a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -83,8 +83,7 @@ nav: - Repository: repository.md - Store: store.md - Event Bus: event_bus.md - - Processor: processor.md - - Projection: projection.md + - Subscription: subscription.md - Advanced: - Aggregate ID: aggregate_id.md - Normalizer: normalizer.md diff --git a/docs/pages/aggregate.md b/docs/pages/aggregate.md index ec4a74f45..5b947dd3e 100644 --- a/docs/pages/aggregate.md +++ b/docs/pages/aggregate.md @@ -321,7 +321,7 @@ final class Profile extends BasicAggregateRoot ## Suppress missing apply methods Sometimes you have events that do not change the state of the aggregate itself, -but are still recorded for the future, to listen on it or to create a projection. +but are still recorded for the future or to subscribe for processor and projection. So that you are not forced to write an apply method for it, you can suppress the missing apply exceptions these events with the `SuppressMissingApply` attribute. @@ -518,8 +518,8 @@ This is not a problem, as the `apply` methods are always executed immediately. In the next case we throw an exception if the hotel is already overbooked. Besides that, we record another event `FullyBooked`, if the hotel is fully booked with the last booking. -With this event we could [notify](./processor.md) external systems -or fill a [projection](./projection.md) with fully booked hotels. +With this event we could [notify](./subscription.md) external systems +or fill a [projection](./subscription.md) with fully booked hotels. ```php use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; @@ -660,5 +660,5 @@ $aggregateRegistry = (new AttributeEventRegistryFactory())->create($paths); * [How to create own aggregate id](aggregate_id.md) * [How to store and load aggregates](repository.md) * [How to snapshot aggregates](snapshots.md) -* [How to create Projections](projection.md) +* [How to create Projections](subscription.md) * [How to split streams](split_stream.md) \ No newline at end of file diff --git a/docs/pages/cli.md b/docs/pages/cli.md index 6f017744e..265af177d 100644 --- a/docs/pages/cli.md +++ b/docs/pages/cli.md @@ -6,7 +6,7 @@ You can: * Create and delete `databases` * Create, update and delete `schemas` -* Manage `projections` +* Manage `subscriptions` ## Database commands @@ -27,22 +27,21 @@ The database schema can also be created, updated and dropped. You can also register doctrine migration commands. -## Projection commands +## Subscription commands -To manage your projectors there are the following cli commands. +To manage your subscriptions there are the following cli commands. -* ProjectionBootCommand: `event-sourcing:projection:boot` -* ProjectionPauseCommand: `event-sourcing:projection:pause` -* ProjectionReactiveCommand: `event-sourcing:projection:reactive` -* ProjectionRebuildCommand: `event-sourcing:projection:rebuild` -* ProjectionRemoveCommand: `event-sourcing:projection:remove` -* ProjectionRunCommand: `event-sourcing:projection:run` -* ProjectionStatusCommand: `event-sourcing:projection:status` -* ProjectionTeardownCommand: `event-sourcing:projection:teardown` +* SubscriptionBootCommand: `event-sourcing:subscription:boot` +* SubscriptionPauseCommand: `event-sourcing:subscription:pause` +* SubscriptionReactiveCommand: `event-sourcing:subscription:reactive` +* SubscriptionRemoveCommand: `event-sourcing:subscription:remove` +* SubscriptionRunCommand: `event-sourcing:subscription:run` +* SubscriptionStatusCommand: `event-sourcing:subscription:status` +* SubscriptionTeardownCommand: `event-sourcing:subscription:teardown` !!! note - You can find out more about projections [here](projection.md). + You can find out more about subscriptions [here](subscription.md). ## Inspector commands @@ -74,14 +73,14 @@ $schemaManager = new DoctrineSchemaManager(); $cli->addCommands(array( new Command\DatabaseCreateCommand($store, $doctrineHelper), new Command\DatabaseDropCommand($store, $doctrineHelper), - new Command\ProjectionBootCommand($projectionist), - new Command\ProjectionPauseCommand($projectionist), - new Command\ProjectionRunCommand($projectionist), - new Command\ProjectionTeardownCommand($projectionist), - new Command\ProjectionRemoveCommand($projectionist), - new Command\ProjectionReactivateCommand($projectionist), - new Command\ProjectionRebuildCommand($projectionist), - new Command\ProjectionStatusCommand($projectionist), + new Command\SubscriptionBootCommand($projectionist), + new Command\SubscriptionPauseCommand($projectionist), + new Command\SubscriptionRunCommand($projectionist), + new Command\SubscriptionTeardownCommand($projectionist), + new Command\SubscriptionRemoveCommand($projectionist), + new Command\SubscriptionReactivateCommand($projectionist), + new Command\SubscriptionRebuildCommand($projectionist), + new Command\SubscriptionStatusCommand($projectionist), new Command\SchemaCreateCommand($store, $schemaManager), new Command\SchemaDropCommand($store, $schemaManager), new Command\SchemaUpdateCommand($store, $schemaManager), diff --git a/docs/pages/event_bus.md b/docs/pages/event_bus.md index b8508edfb..db6268e96 100644 --- a/docs/pages/event_bus.md +++ b/docs/pages/event_bus.md @@ -233,6 +233,5 @@ $eventBus = new Psr14EventBus($psr14EventDispatcher); * [How to decorate messages](message_decorator.md) * [How to use outbox pattern](outbox.md) -* [How to use processor](processor.md) -* [How to use projections](projection.md) -* [How to debug messages with the watch server](watch_server.md) +* [How to use processor](subscription.md) +* [How to use subscriptions](subscription.md) diff --git a/docs/pages/getting_started.md b/docs/pages/getting_started.md index 9b8c53ea1..7c3bf5559 100644 --- a/docs/pages/getting_started.md +++ b/docs/pages/getting_started.md @@ -154,22 +154,21 @@ final class Hotel extends BasicAggregateRoot So that we can see all the hotels on our website and also see how many guests are currently visiting the hotels, we need a projection for it. To create a projection we need a projector. -Each projector is then responsible for a specific projection and version. +Each subscriber is then responsible for a specific projection. ```php use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Projection\Projection\ProjectionId; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; -#[Projector('hotel')] +#[Subscriber('hotel')] final class HotelProjector { - use ProjectorUtil; + use SubscriberUtil; public function __construct( private readonly Connection $db @@ -231,14 +230,14 @@ final class HotelProjector private function table(): string { - return 'projection_' . $this->projectorId(); + return 'projection_' . $this->subscriberId(); } } ``` !!! note - You can find out more about projections [here](projection.md). + You can find out more about subscriptions [here](subscription.md). ## Processor @@ -270,7 +269,7 @@ final class SendCheckInEmailProcessor !!! note - You can find out more about processor [here](processor.md). + You can find out more about processor [here](subscription.md). ## Configuration @@ -279,9 +278,9 @@ After we have defined everything, we still have to plug the whole thing together ```php use Doctrine\DBAL\DriverManager; use Patchlevel\EventSourcing\EventBus\DefaultEventBus; -use Patchlevel\EventSourcing\Projection\Projection\Store\DoctrineStore; -use Patchlevel\EventSourcing\Projection\Projectionist\DefaultProjectionist; -use Patchlevel\EventSourcing\Projection\Projector\MetadataProjectorAccessorRepository; +use Patchlevel\EventSourcing\Projection\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Projection\Subscriber\MetadataSubscriberAccessorRepository; +use Patchlevel\EventSourcing\Projection\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; @@ -307,13 +306,13 @@ $eventStore = new DoctrineDbalStore( $hotelProjector = new HotelProjector($projectionConnection); -$projectorRepository = new MetadataProjectorAccessorRepository([ +$projectorRepository = new MetadataSubscriberAccessorRepository([ $hotelProjector, ]); -$projectionStore = new DoctrineStore($connection); +$projectionStore = new DoctrineSubscriptionStore($connection); -$projectionist = new DefaultProjectionist( +$projectionist = new DefaultSubscriptionEngine( $eventStore, $projectionStore, $projectorRepository, @@ -407,6 +406,6 @@ $hotels = $hotelProjection->getHotels(); * [How to create an aggregate](aggregate.md) * [How to create an event](events.md) * [How to store aggregates](repository.md) -* [How to process events](processor.md) -* [How to create a projection](projection.md) +* [How to process events](subscription.md) +* [How to create a projection](subscription.md) * [How to setup the database](store.md) \ No newline at end of file diff --git a/docs/pages/index.md b/docs/pages/index.md index a0c68346b..c945298a1 100644 --- a/docs/pages/index.md +++ b/docs/pages/index.md @@ -10,7 +10,7 @@ A lightweight but also all-inclusive event sourcing library with a focus on deve * Automatic [snapshot](snapshots.md)-system to boost your performance * [Split](split_stream.md) big aggregates into multiple streams * Build-in [pipeline](pipeline.md) to export, import and migrate event streams -* Versioned and managed lifecycle of [projections](projection.md) +* Versioned and managed lifecycle of [subscriptions](subscription.md) like projections and processors * Smooth [upcasting](upcasting.md) of old events * Simple setup with [scheme management](store.md) and [doctrine migration](migration.md) * Built in [cli commands](cli.md) with [symfony](https://symfony.com/) diff --git a/docs/pages/processor.md b/docs/pages/processor.md deleted file mode 100644 index 1dec6c5e0..000000000 --- a/docs/pages/processor.md +++ /dev/null @@ -1,38 +0,0 @@ -# Processor - -The `processor` is a kind of [event bus](./event_bus.md) listener that can execute actions on certain events. -A process can be for example used to send an email when a profile has been created: - -## Listener - -Here is an example with a listener. - -```php -use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\EventBus\Listener; -use Patchlevel\EventSourcing\EventBus\Message; - -final class SendEmailProcessor -{ - public function __construct( - private readonly Mailer $mailer - ) { - } - - #[Subscribe(ProfileCreated::class)] - public function __invoke(Message $message): void - { - $event = $message->event(); - - $this->mailer->send( - $event->email, - 'Profile created', - '...' - ); - } -} -``` - -!!! tip - - You can find out more about the event bus [here](event_bus.md). diff --git a/docs/pages/projection.md b/docs/pages/projection.md deleted file mode 100644 index a57b3246d..000000000 --- a/docs/pages/projection.md +++ /dev/null @@ -1,591 +0,0 @@ -# Projections - -With `projections` you can transform your data optimized for reading. -projections can be adjusted, deleted or rebuilt at any time. -This is possible because the event store remains untouched -and everything can always be reproduced from the events. - -A projection can be anything. -Either a file, a relational database, a no-sql database like mongodb or an elasticsearch. - -## Projector - -To create a projection you need a projector with a unique ID named `projectorId`. -This projector is responsible for a specific projection. -To do this, you can use the `Projector` attribute. - -```php -use Doctrine\DBAL\Connection; -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; - -#[Projector('profile_1')] -final class ProfileProjector -{ - use ProjectorUtil; - - public function __construct( - private readonly Connection $connection - ) { - } -} -``` - -!!! tip - - Add a version as suffix to the `projectorId`, - so you can increment it when the projection changes. - Like `profile_1` to `profile_2`. - -!!! warning - - MySQL and MariaDB don't support transactions for DDL statements. - So you must use a different database connection for your projections. - -### Subscribe - -A projector can subscribe any number of events. -In order to say which method is responsible for which event, you need the `Subscribe` attribute. -There you can pass the event class to which the reaction should then take place. -The method itself must expect a `Message`, which then contains the event. -The method name itself doesn't matter. - -```php -use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; - -#[Projector('profile_1')] -final class ProfileProjector -{ - use ProjectorUtil; - - // ... - - #[Subscribe(ProfileCreated::class)] - public function handleProfileCreated(Message $message): void - { - $profileCreated = $message->event(); - - $this->connection->executeStatement( - "INSERT INTO {$this->table()} (id, name) VALUES(?, ?);", - [ - 'id' => $profileCreated->profileId->toString(), - 'name' => $profileCreated->name - ] - ); - } - - private function table(): string - { - return 'projection_' . $this->projectionId(); - } -} -``` - -!!! warning - - You have to be careful with actions because in default it will be executed from the start of the event stream. - Even if you change the ProjectionId, it will run again from the start. - -!!! note - - You can subscribe to multiple events on the same method or you can use "*" to subscribe to all events. - More about this can be found [here](./event_bus.md#listener). - -!!! tip - - If you are using psalm then you can install the event sourcing [plugin](https://github.com/patchlevel/event-sourcing-psalm-plugin) - to make the event method return the correct type. - -### Setup and Teardown - -Projectors can have one `setup` and `teardown` method that is executed when the projection is created or deleted. -For this there are the attributes `Setup` and `Teardown`. The method name itself doesn't matter. -In some cases it may be that no schema has to be created for the projection, -as the target does it automatically, so you can skip this. - -```php -use Patchlevel\EventSourcing\Attribute\Setup; -use Patchlevel\EventSourcing\Attribute\Teardown; -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; - -#[Projector('profile_1')] -final class ProfileProjector -{ - use ProjectorUtil; - - // ... - - #[Setup] - public function create(): void - { - $this->connection->executeStatement( - "CREATE TABLE IF NOT EXISTS {$this->table()} (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);" - ); - } - - #[Teardown] - public function drop(): void - { - $this->connection->executeStatement("DROP TABLE IF EXISTS {$this->table()};"); - } - - private function table(): string - { - return 'projection_' . $this->projectionId(); - } -} -``` - -!!! warning - - If you change the `projectorID`, you must also change the table/collection name. - Otherwise the table/collection will conflict with the old projection. - -!!! note - - Most databases have a limit on the length of the table/collection name. - The limit is usually 64 characters. - -!!! tip - - You can also use the `ProjectorUtil` to build the table/collection name. - -### Read Model - -You can also implement your read model here. -You can offer methods that then read the data and put it into a specific format. - -```php -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; - -#[Projector('profile_1')] -final class ProfileProjector -{ - use ProjectorUtil; - - // ... - - /** - * @return list - */ - public function getProfiles(): array - { - return $this->connection->fetchAllAssociative("SELECT id, name FROM {$this->table()};"); - } - - private function table(): string - { - return 'projection_' . $this->projectionId(); - } -} -``` - -!!! tip - - You can also use the `ProjectorUtil` to build the table/collection name. - -### Versioning - -As soon as the structure of a projection changes, or you need other events from the past, -the `projectorId` must be change or increment. - -Otherwise, the projectionist will not recognize that the projection has changed and will not rebuild it. -To do this, you can add a version to the `projectorId`: - -```php -use Patchlevel\EventSourcing\Attribute\Projector; - -#[Projector('profile_2')] -final class ProfileProjector -{ - // ... -} -``` - -!!! warning - - If you change the `projectorID`, you must also change the table/collection name. - Otherwise the table/collection will conflict with the old projection. - -### Grouping - -You can also group projectors and address these to the projectionist. -This is useful if you want to run projectors in different processes or on different servers. - -```php -use Patchlevel\EventSourcing\Attribute\Projector; - -#[Projector('profile_1', group: 'a')] -final class ProfileProjector -{ - // ... -} -``` - -!!! note - - The default group is `default` and the projectionist takes all groups if none are given to him. - -### Run Mode - -The run mode determines how the projector should behave when it is booted. -There are three different modes: - -#### From Beginning - -This is the default mode. -The projector will start from the beginning of the event stream and process all events. - -```php -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; - -#[Projector('welcome_email', runMode: RunMode::FromBeginning)] -final class WelcomeEmailProjector -{ - // ... -} -``` - -#### From Now - -Certain projectors operate exclusively on post-release events, disregarding historical data. -This is useful for projectors that are only interested in events that occur after a certain point in time. -As example, a welcome email projector that only wants to send emails to new users. - -```php -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; - -#[Projector('welcome_email', runMode: RunMode::FromNow)] -final class WelcomeEmailProjector -{ - // ... -} -``` - -#### Once - -This mode is useful for projectors that only need to run once. -This is useful for projectors to create reports or to migrate data. - -```php -use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; - -#[Projector('migration', runMode: RunMode::Once)] -final class MigrationProjector -{ - // ... -} -``` - -## Projectionist - -The projectionist manages individual projectors and keeps the projections running. -Internally, the projectionist does this by tracking where each projector is in the event stream -and keeping all projections up to date. -He also takes care that new projectors are booted and old ones are removed again. -If something breaks, the projectionist marks the individual projections as faulty. - -!!! tip - - The Projectionist was inspired by the following two blog posts: - - * [Projection Building Blocks: What you'll need to build projections](https://barryosull.com/blog/projection-building-blocks-what-you-ll-need-to-build-projections/) - * [Managing projectors is harder than you think](https://barryosull.com/blog/managing-projectors-is-harder-than-you-think/) - -## Projection ID - -The projection ID is taken from the associated projector and corresponds to the projector ID. -Unlike the projector ID, the projection ID can no longer change. -If the Projector ID is changed, a new projection will be created with this new projector ID. -So there are two projections, one with the old projector ID and one with the new projector ID. - -## Projection Position - -Furthermore, the position in the event stream is stored for each projection. -So that the projectionist knows where the projection stopped and must continue. - -## Projection Status - -There is a lifecycle for each projection. -This cycle is tracked by the projectionist. - -``` mermaid -stateDiagram-v2 - direction LR - [*] --> New - New --> Booting - New --> Error - Booting --> Active - Booting --> Paused - Booting --> Finished - Booting --> Error - Active --> Paused - Active --> Finished - Active --> Outdated - Active --> Error - Paused --> New - Paused --> Booting - Paused --> Active - Paused --> Outdated - Paused --> [*] - Finished --> Active - Finished --> Outdated - Error --> New - Error --> Booting - Error --> Active - Error --> Paused - Error --> [*] - Outdated --> Active - Outdated --> [*] -``` - -### New - -A projection is created and "new" if a projector exists with an ID that is not yet tracked. -This can happen when either a new projector has been added, the `projector id` has changed -or the projection has been manually deleted from the projection store. - -### Booting - -Booting status is reached when the boot process is invoked. -In this step, the "setup" method is called on the projection, if available. -And the projection is brought up to date, depending on the mode. -When the process is finished, the projection is set to active. - -### Active - -The active status describes the projections currently being actively managed by the projectionist. -These projections have a projector, follow the event stream and should be up-to-date. - -## Paused - -A projection can manually be paused. It will then no longer be updated by the projectionist. -This can be useful if you want to pause a projection for a certain period of time. -You can also reactivate the projection if you want so that it continues. - -### Finished - -A projection is finished if the projector has the mode `RunMode::Once`. -This means that the projection is only run once and then set to finished if it reaches the end of the event stream. -You can also reactivate the projection if you want so that it continues. - -### Outdated - -If an active or finished projection exists in the projection store -that does not have a projector in the source code with a corresponding projector ID, -then this projection is marked as outdated. -This happens when either the projector has been deleted -or the projector ID of a projector has changed. -In the last case there should be a new projection with the new projector ID. - -An outdated projection does not automatically become active again when the projector exists again. -This happens, for example, when an old version was deployed again during a rollback. - -There are two options to reactivate the projection: - -* Reactivate the projection, so that the projection is active again. -* Remove the projection and rebuild it from scratch. - -### Error - -If an error occurs in a projector, then the target projection is set to Error. -This can happen in the create process, in the boot process or in the run process. -This projection will then no longer boot/run until the projection is reactivate or retried. - -The projectionist has a retry strategy to retry projections that have failed. -It tries to reactivate the projection after a certain time and a certain number of attempts. -If this does not work, the projection is set to error and must be manually reactivated. - -There are two options here: - -* Reactivate the projection, so that the projection is in the previous state again. -* Remove the projection and rebuild it from scratch. - -## Setup - -In order for the projectionist to be able to do its work, you have to assemble it beforehand. - -### Projection Store - -The Projectionist uses a projection store to store the status of each projection. -We provide a Doctrine implementation of this by default. - -```php -use Patchlevel\EventSourcing\Projection\Projection\Store\DoctrineStore; - -$projectionStore = new DoctrineStore($connection); -``` - -So that the schema for the projection store can also be created, -we have to tell the `SchemaDirector` our schema configuration. -Using `ChainSchemaConfigurator` we can add multiple schema configurators. -In our case they need the `SchemaConfigurator` from the event store and projection store. - -```php -use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; -use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; - -$schemaDirector = new DoctrineSchemaDirector( - $connection - new ChainDoctrineSchemaConfigurator([ - $eventStore, - $projectionStore - ]), -); -``` - -!!! note - - You can find more about schema configurator [here](./store.md) - -### Retry Strategy - -The projectionist uses a retry strategy to retry projections that have failed. -Our default strategy can be configured with the following parameters: - -* `baseDelay` - The base delay in seconds. -* `delayFactor` - The factor by which the delay is multiplied after each attempt. -* `maxAttempts` - The maximum number of attempts. - -```php -use Patchlevel\EventSourcing\Projection\RetryStrategy\ClockBasedRetryStrategy; - -$retryStrategy = new ClockBasedRetryStrategy( - baseDelay: 5, - delayFactor: 2, - maxAttempts: 5, -); -``` - -!!! tip - - You can reactivate the projection manually or remove it and rebuild it from scratch. - -### Projector Accessor - -The projector accessor is responsible for providing the projectors to the projectionist. -We provide a metadata projector accessor repository by default. - -```php -use Patchlevel\EventSourcing\Projection\Projector\MetadataProjectorAccessorRepository; - -$projectorAccessorRepository = new MetadataProjectorAccessorRepository([$projector1, $projector2, $projector3]); -``` - -### Projectionist - -Now we can create the projectionist and plug together the necessary services. -The event store is needed to load the events, the Projection Store to store the projection state -and the respective projectors. Optionally, we can also pass a retry strategy. - -```php -use Patchlevel\EventSourcing\Projection\Projectionist\DefaultProjectionist; - -$projectionist = new DefaultProjectionist( - $eventStore, - $projectionStore, - $projectorAccessorRepository, - $retryStrategy, -); -``` - -## Usage - -The Projectionist has a few methods needed to use it effectively. -A `ProjectionistCriteria` can be passed to all of these methods to filter the respective projectors. - -```php -use Patchlevel\EventSourcing\Projection\Projectionist\ProjectionistCriteria; - -$criteria = new ProjectionistCriteria( - ids: ['profile_1', 'welcome_email'], - groups: ['default'] -); -``` - -!!! note - - An `OR` check is made for the respective criteria and all criteria are checked with an `AND`. - -### Boot - -So that the projectionist can manage the projections, they must be booted. -In this step, the structures are created for all new projections. -The projections then catch up with the current position of the event stream. -When the projections are finished, they switch to the active state. - -```php -$projectionist->boot($criteria); -``` - -### Run - -All active projections are continued and updated here. - -```php -$projectionist->run($criteria); -``` - -### Teardown - -If projections are outdated, they can be cleaned up here. -The projectionist also tries to remove the structures created for the projection. - -```php -$projectionist->teardown($criteria); -``` - -### Remove - -You can also directly remove a projection regardless of its status. -An attempt is made to remove the structures, but the entry will still be removed if it doesn't work. - -```php -$projectionist->remove($criteria); -``` - -### Reactivate - -If a projection had an error, you can reactivate it. -As a result, the projection gets the status active again and is then kept up-to-date again by the projectionist. - -```php -$projectionist->reactivate($criteria); -``` - -### Pause - -Pausing a projection is also possible. -The projection will then no longer be updated by the projectionist. -You can reactivate the projection if you want so that it continues. - -```php -$projectionist->pause($criteria); -``` - -### Status - -To get the current status of all projections, you can get them using the `projections` method. - -```php -$projections = $projectionist->projections($criteria); - -foreach ($projections as $projection) { - echo $projection->status(); -} -``` - -## Learn more - -* [How to use CLI commands](./cli.md) -* [How to use Pipeline](./pipeline.md) -* [How to use Event Bus](./event_bus.md) -* [How to Test](./testing.md) \ No newline at end of file diff --git a/docs/pages/subscription.md b/docs/pages/subscription.md new file mode 100644 index 000000000..bbf3b1809 --- /dev/null +++ b/docs/pages/subscription.md @@ -0,0 +1,588 @@ +# Subscriptions + +With `subscriptions` you can transform your data optimized for reading. +Subscriptions can be adjusted, deleted or rebuilt at any time. +This is possible because the event store remains untouched +and everything can always be reproduced from the events. + +A subscription can be anything. +Either a file, a relational database, a no-sql database like mongodb or an elasticsearch. + +## Subscriber + +To create a subscription you need a subscriber with a unique ID named `subscriberId`. +This subscriber is responsible for a specific subscription. +To do this, you can use the `Subscriber` attribute. + +```php +use Doctrine\DBAL\Connection; +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; + +#[Subscriber('profile_1')] +final class ProfileSubscriber +{ + use SubscriberUtil; + + public function __construct( + private readonly Connection $connection + ) { + } +} +``` + +!!! tip + + Add a version as suffix to the `subscriberId`, + so you can increment it when the subscription changes. + Like `profile_1` to `profile_2`. + +!!! warning + + MySQL and MariaDB don't support transactions for DDL statements. + So you must use a different database connection for your subscriptions. + +### Subscribe + +A subscriber can subscribe any number of events. +In order to say which method is responsible for which event, you need the `Subscribe` attribute. +There you can pass the event class to which the reaction should then take place. +The method itself must expect a `Message`, which then contains the event. +The method name itself doesn't matter. + +```php +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\EventBus\Message; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; + +#[Subscriber('profile_1')] +final class ProfileSubscriber +{ + use SubscriberUtil; + + // ... + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(Message $message): void + { + $profileCreated = $message->event(); + + $this->connection->executeStatement( + "INSERT INTO {$this->table()} (id, name) VALUES(?, ?);", + [ + 'id' => $profileCreated->profileId->toString(), + 'name' => $profileCreated->name + ] + ); + } + + private function table(): string + { + return 'subscription_' . $this->subscriptionId(); + } +} +``` + +!!! warning + + You have to be careful with actions because in default it will be executed from the start of the event stream. + Even if you change the SubscriptionId, it will run again from the start. + +!!! note + + You can subscribe to multiple events on the same method or you can use "*" to subscribe to all events. + More about this can be found [here](./event_bus.md#listener). + +!!! tip + + If you are using psalm then you can install the event sourcing [plugin](https://github.com/patchlevel/event-sourcing-psalm-plugin) + to make the event method return the correct type. + +### Setup and Teardown + +Subscribers can have one `setup` and `teardown` method that is executed when the subscription is created or deleted. +For this there are the attributes `Setup` and `Teardown`. The method name itself doesn't matter. +In some cases it may be that no schema has to be created for the subscription, +as the target does it automatically, so you can skip this. + +```php +use Patchlevel\EventSourcing\Attribute\Setup; +use Patchlevel\EventSourcing\Attribute\Teardown; +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; + +#[Subscriber('profile_1')] +final class ProfileSubscriber +{ + use SubscriberUtil; + + // ... + + #[Setup] + public function create(): void + { + $this->connection->executeStatement( + "CREATE TABLE IF NOT EXISTS {$this->table()} (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);" + ); + } + + #[Teardown] + public function drop(): void + { + $this->connection->executeStatement("DROP TABLE IF EXISTS {$this->table()};"); + } + + private function table(): string + { + return 'subscription_' . $this->subscriptionId(); + } +} +``` + +!!! warning + + If you change the `subscriberID`, you must also change the table/collection name. + Otherwise the table/collection will conflict with the old subscription. + +!!! note + + Most databases have a limit on the length of the table/collection name. + The limit is usually 64 characters. + +!!! tip + + You can also use the `SubscriberUtil` to build the table/collection name. + +### Read Model + +You can also implement your read model here. +You can offer methods that then read the data and put it into a specific format. + +```php +use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; + +#[Subscriber('profile_1')] +final class ProfileSubscriber +{ + use SubscriberUtil; + + // ... + + /** + * @return list + */ + public function getProfiles(): array + { + return $this->connection->fetchAllAssociative("SELECT id, name FROM {$this->table()};"); + } + + private function table(): string + { + return 'subscription_' . $this->subscriptionId(); + } +} +``` + +!!! tip + + You can also use the `SubscriberUtil` to build the table/collection name. + +### Versioning + +As soon as the structure of a subscription changes, or you need other events from the past, +the `subscriberId` must be change or increment. + +Otherwise, the subscription engine will not recognize that the subscription has changed and will not rebuild it. +To do this, you can add a version to the `subscriberId`: + +```php +use Patchlevel\EventSourcing\Attribute\Subscriber; + +#[Subscriber('profile_2')] +final class ProfileSubscriber +{ + // ... +} +``` + +!!! warning + + If you change the `subscriberID`, you must also change the table/collection name. + Otherwise the table/collection will conflict with the old subscription. + +### Grouping + +You can also group subscribers and address these to the subscription engine. +This is useful if you want to run subscribers in different processes or on different servers. + +```php +use Patchlevel\EventSourcing\Attribute\Subscriber; + +#[Subscriber('profile_1', group: 'a')] +final class ProfileSubscriber +{ + // ... +} +``` + +!!! note + + The default group is `default` and the subscription engine takes all groups if none are given to him. + +### Run Mode + +The run mode determines how the subscriber should behave when it is booted. +There are three different modes: + +#### From Beginning + +This is the default mode. +The subscriber will start from the beginning of the event stream and process all events. + +```php +use Patchlevel\EventSourcing\Attribute\Subscriber;use Patchlevel\EventSourcing\Subscription\RunMode; + +#[Subscriber('welcome_email', runMode: RunMode::FromBeginning)] +final class WelcomeEmailSubscriber +{ + // ... +} +``` + +#### From Now + +Certain subscribers operate exclusively on post-release events, disregarding historical data. +This is useful for subscribers that are only interested in events that occur after a certain point in time. +As example, a welcome email subscriber that only wants to send emails to new users. + +```php +use Patchlevel\EventSourcing\Attribute\Subscriber;use Patchlevel\EventSourcing\Subscription\RunMode; + +#[Subscriber('welcome_email', runMode: RunMode::FromNow)] +final class WelcomeEmailSubscriber +{ + // ... +} +``` + +#### Once + +This mode is useful for subscribers that only need to run once. +This is useful for subscribers to create reports or to migrate data. + +```php +use Patchlevel\EventSourcing\Attribute\Subscriber;use Patchlevel\EventSourcing\Subscription\RunMode; + +#[Subscriber('migration', runMode: RunMode::Once)] +final class MigrationSubscriber +{ + // ... +} +``` + +## Subscription Engine + +The subscription engine manages individual subscribers and keeps the subscriptions running. +Internally, the subscription engine does this by tracking where each subscriber is in the event stream +and keeping all subscriptions up to date. +He also takes care that new subscribers are booted and old ones are removed again. +If something breaks, the subscription engine marks the individual subscriptions as faulty. + +!!! tip + + The Subscription Engine was inspired by the following two blog posts: + + * [Projection Building Blocks: What you'll need to build projections](https://barryosull.com/blog/projection-building-blocks-what-you-ll-need-to-build-projections/) + * [Managing projectors is harder than you think](https://barryosull.com/blog/managing-projectors-is-harder-than-you-think/) + +## Subscription ID + +The subscription ID is taken from the associated subscriber and corresponds to the subscriber ID. +Unlike the subscriber ID, the subscription ID can no longer change. +If the Subscriber ID is changed, a new subscription will be created with this new subscriber ID. +So there are two subscriptions, one with the old subscriber ID and one with the new subscriber ID. + +## Subscription Position + +Furthermore, the position in the event stream is stored for each subscription. +So that the subscription engine knows where the subscription stopped and must continue. + +## Subscription Status + +There is a lifecycle for each subscription. +This cycle is tracked by the subscription engine. + +``` mermaid +stateDiagram-v2 + direction LR + [*] --> New + New --> Booting + New --> Error + Booting --> Active + Booting --> Paused + Booting --> Finished + Booting --> Error + Active --> Paused + Active --> Finished + Active --> Outdated + Active --> Error + Paused --> New + Paused --> Booting + Paused --> Active + Paused --> Outdated + Paused --> [*] + Finished --> Active + Finished --> Outdated + Error --> New + Error --> Booting + Error --> Active + Error --> Paused + Error --> [*] + Outdated --> Active + Outdated --> [*] +``` + +### New + +A subscription is created and "new" if a subscriber exists with an ID that is not yet tracked. +This can happen when either a new subscriber has been added, the `subscriber id` has changed +or the subscription has been manually deleted from the subscription store. + +### Booting + +Booting status is reached when the boot process is invoked. +In this step, the "setup" method is called on the subscription, if available. +And the subscription is brought up to date, depending on the mode. +When the process is finished, the subscription is set to active. + +### Active + +The active status describes the subscriptions currently being actively managed by the subscription engine. +These subscriptions have a subscriber, follow the event stream and should be up-to-date. + +## Paused + +A subscription can manually be paused. It will then no longer be updated by the subscription engine. +This can be useful if you want to pause a subscription for a certain period of time. +You can also reactivate the subscription if you want so that it continues. + +### Finished + +A subscription is finished if the subscriber has the mode `RunMode::Once`. +This means that the subscription is only run once and then set to finished if it reaches the end of the event stream. +You can also reactivate the subscription if you want so that it continues. + +### Outdated + +If an active or finished subscription exists in the subscription store +that does not have a subscriber in the source code with a corresponding subscriber ID, +then this subscription is marked as outdated. +This happens when either the subscriber has been deleted +or the subscriber ID of a subscriber has changed. +In the last case there should be a new subscription with the new subscriber ID. + +An outdated subscription does not automatically become active again when the subscriber exists again. +This happens, for example, when an old version was deployed again during a rollback. + +There are two options to reactivate the subscription: + +* Reactivate the subscription, so that the subscription is active again. +* Remove the subscription and rebuild it from scratch. + +### Error + +If an error occurs in a subscriber, then the target subscription is set to Error. +This can happen in the create process, in the boot process or in the run process. +This subscription will then no longer boot/run until the subscription is reactivate or retried. + +The subscription engine has a retry strategy to retry subscriptions that have failed. +It tries to reactivate the subscription after a certain time and a certain number of attempts. +If this does not work, the subscription is set to error and must be manually reactivated. + +There are two options here: + +* Reactivate the subscription, so that the subscription is in the previous state again. +* Remove the subscription and rebuild it from scratch. + +## Setup + +In order for the subscription engine to be able to do its work, you have to assemble it beforehand. + +### Subscription Store + +The Subscription Engine uses a subscription store to store the status of each subscription. +We provide a Doctrine implementation of this by default. + +```php +use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; + +$subscriptionStore = new DoctrineSubscriptionStore($connection); +``` + +So that the schema for the subscription store can also be created, +we have to tell the `SchemaDirector` our schema configuration. +Using `ChainSchemaConfigurator` we can add multiple schema configurators. +In our case they need the `SchemaConfigurator` from the event store and subscription store. + +```php +use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; +use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; + +$schemaDirector = new DoctrineSchemaDirector( + $connection + new ChainDoctrineSchemaConfigurator([ + $eventStore, + $subscriptionStore + ]), +); +``` + +!!! note + + You can find more about schema configurator [here](./store.md) + +### Retry Strategy + +The subscription engine uses a retry strategy to retry subscriptions that have failed. +Our default strategy can be configured with the following parameters: + +* `baseDelay` - The base delay in seconds. +* `delayFactor` - The factor by which the delay is multiplied after each attempt. +* `maxAttempts` - The maximum number of attempts. + +```php +use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; + +$retryStrategy = new ClockBasedRetryStrategy( + baseDelay: 5, + delayFactor: 2, + maxAttempts: 5, +); +``` + +!!! tip + + You can reactivate the subscription manually or remove it and rebuild it from scratch. + +### Subscriber Accessor + +The subscriber accessor is responsible for providing the subscribers to the subscription engine. +We provide a metadata subscriber accessor repository by default. + +```php +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; + +$subscriberAccessorRepository = new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2, $subscriber3]); +``` + +### Subscription Engine + +Now we can create the subscription engine and plug together the necessary services. +The event store is needed to load the events, the Subscription Store to store the subscription state +and the respective subscribers. Optionally, we can also pass a retry strategy. + +```php +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; + +$subscriptionEngine = new DefaultSubscriptionEngine( + $eventStore, + $subscriptionStore, + $subscriberAccessorRepository, + $retryStrategy, +); +``` + +## 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 subscribers. + +```php +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; + +$criteria = new SubscriptionEngineCriteria( + ids: ['profile_1', 'welcome_email'], + groups: ['default'] +); +``` + +!!! note + + An `OR` check is made for the respective criteria and all criteria are checked with an `AND`. + +### Boot + +So that the subscription engine can manage the subscriptions, they must be booted. +In this step, the structures are created for all new subscriptions. +The subscriptions then catch up with the current position of the event stream. +When the subscriptions are finished, they switch to the active state. + +```php +$subscriptionEngine->boot($criteria); +``` + +### Run + +All active subscriptions are continued and updated here. + +```php +$subscriptionEngine->run($criteria); +``` + +### Teardown + +If subscriptions are outdated, they can be cleaned up here. +The subscription engine also tries to remove the structures created for the subscription. + +```php +$subscriptionEngine->teardown($criteria); +``` + +### Remove + +You can also directly remove a subscription regardless of its status. +An attempt is made to remove the structures, but the entry will still be removed if it doesn't work. + +```php +$subscriptionEngine->remove($criteria); +``` + +### Reactivate + +If a subscription had an error, you can reactivate it. +As a result, the subscription gets the status active again and is then kept up-to-date again by the subscription engine. + +```php +$subscriptionEngine->reactivate($criteria); +``` + +### Pause + +Pausing a subscription is also possible. +The subscription will then no longer be updated by the subscription engine. +You can reactivate the subscription if you want so that it continues. + +```php +$subscriptionEngine->pause($criteria); +``` + +### Status + +To get the current status of all subscriptions, you can get them using the `subscriptions` method. + +```php +$subscriptions = $subscriptionEngine->subscriptions($criteria); + +foreach ($subscriptions as $subscription) { + echo $subscription->status(); +} +``` + +## Learn more + +* [How to use CLI commands](./cli.md) +* [How to use Pipeline](./pipeline.md) +* [How to use Event Bus](./event_bus.md) +* [How to Test](./testing.md) \ No newline at end of file diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 11c8e8a71..e97903bba 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -5,11 +5,6 @@ parameters: count: 1 path: src/Console/DoctrineHelper.php - - - message: "#^Parameter \\#3 \\$errorContext of class Patchlevel\\\\EventSourcing\\\\Projection\\\\Projection\\\\ProjectionError constructor expects array\\\\}\\>\\|null, mixed given\\.$#" - count: 1 - path: src/Projection/Projection/Store/DoctrineStore.php - - message: "#^Parameter \\#2 \\$data of method Patchlevel\\\\Hydrator\\\\Hydrator\\:\\:hydrate\\(\\) expects array\\, mixed given\\.$#" count: 1 @@ -29,3 +24,8 @@ parameters: message: "#^Ternary operator condition is always true\\.$#" count: 1 path: src/Store/DoctrineDbalStoreStream.php + + - + message: "#^Parameter \\#3 \\$errorContext of class Patchlevel\\\\EventSourcing\\\\Subscription\\\\SubscriptionError constructor expects array\\\\}\\>\\|null, mixed given\\.$#" + count: 1 + path: src/Subscription/Store/DoctrineSubscriptionStore.php diff --git a/src/Attribute/Projector.php b/src/Attribute/Subscriber.php similarity index 57% rename from src/Attribute/Projector.php rename to src/Attribute/Subscriber.php index a45a7586a..4908551c6 100644 --- a/src/Attribute/Projector.php +++ b/src/Attribute/Subscriber.php @@ -5,15 +5,15 @@ namespace Patchlevel\EventSourcing\Attribute; use Attribute; -use Patchlevel\EventSourcing\Projection\Projection\Projection; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; +use Patchlevel\EventSourcing\Subscription\RunMode; +use Patchlevel\EventSourcing\Subscription\Subscription; #[Attribute(Attribute::TARGET_CLASS)] -final class Projector +final class Subscriber { public function __construct( public readonly string $id, - public readonly string $group = Projection::DEFAULT_GROUP, + public readonly string $group = Subscription::DEFAULT_GROUP, public readonly RunMode $runMode = RunMode::FromBeginning, ) { } diff --git a/src/Console/Command/ProjectionRebuildCommand.php b/src/Console/Command/ProjectionRebuildCommand.php deleted file mode 100644 index 137333d31..000000000 --- a/src/Console/Command/ProjectionRebuildCommand.php +++ /dev/null @@ -1,33 +0,0 @@ -projectionCriteria($input); - - if (!$io->confirm('do you want to rebuild all projections?', false)) { - return 1; - } - - $this->projectionist->remove($criteria); - $this->projectionist->boot($criteria, null); - - return 0; - } -} diff --git a/src/Console/Command/ProjectionBootCommand.php b/src/Console/Command/SubscriptionBootCommand.php similarity index 75% rename from src/Console/Command/ProjectionBootCommand.php rename to src/Console/Command/SubscriptionBootCommand.php index 387224dba..e63f864eb 100644 --- a/src/Console/Command/ProjectionBootCommand.php +++ b/src/Console/Command/SubscriptionBootCommand.php @@ -11,10 +11,10 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - 'event-sourcing:projection:boot', - 'Prepare new projections and catch up with the event store', + 'event-sourcing:subscription:boot', + 'Prepare new subscriptions and catch up with the event store', )] -final class ProjectionBootCommand extends ProjectionCommand +final class SubscriptionBootCommand extends SubscriptionCommand { public function configure(): void { @@ -33,8 +33,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $limit = InputHelper::nullablePositiveInt($input->getOption('limit')); - $criteria = $this->projectionCriteria($input); - $this->projectionist->boot($criteria, $limit); + $criteria = $this->subscriptionEngineCriteria($input); + $this->engine->boot($criteria, $limit); return 0; } diff --git a/src/Console/Command/ProjectionCommand.php b/src/Console/Command/SubscriptionCommand.php similarity index 65% rename from src/Console/Command/ProjectionCommand.php rename to src/Console/Command/SubscriptionCommand.php index 2314882d1..8c91b0db1 100644 --- a/src/Console/Command/ProjectionCommand.php +++ b/src/Console/Command/SubscriptionCommand.php @@ -5,17 +5,17 @@ namespace Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\InputHelper; -use Patchlevel\EventSourcing\Projection\Projectionist\Projectionist; -use Patchlevel\EventSourcing\Projection\Projectionist\ProjectionistCriteria; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; /** @interal */ -abstract class ProjectionCommand extends Command +abstract class SubscriptionCommand extends Command { public function __construct( - protected readonly Projectionist $projectionist, + protected readonly SubscriptionEngine $engine, ) { parent::__construct(); } @@ -27,19 +27,19 @@ protected function configure(): void 'id', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'filter by projection id', + 'filter by subscription id', ) ->addOption( 'group', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED, - 'filter by projection group', + 'filter by subscription group', ); } - protected function projectionCriteria(InputInterface $input): ProjectionistCriteria + protected function subscriptionEngineCriteria(InputInterface $input): SubscriptionEngineCriteria { - return new ProjectionistCriteria( + return new SubscriptionEngineCriteria( InputHelper::nullableStringList($input->getOption('id')), InputHelper::nullableStringList($input->getOption('group')), ); diff --git a/src/Console/Command/ProjectionPauseCommand.php b/src/Console/Command/SubscriptionPauseCommand.php similarity index 60% rename from src/Console/Command/ProjectionPauseCommand.php rename to src/Console/Command/SubscriptionPauseCommand.php index 7ab4626ba..50aa8a8f8 100644 --- a/src/Console/Command/ProjectionPauseCommand.php +++ b/src/Console/Command/SubscriptionPauseCommand.php @@ -9,15 +9,15 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - 'event-sourcing:projection:pause', - 'Set projection to pause', + 'event-sourcing:subscription:pause', + 'Set subscription to pause', )] -final class ProjectionPauseCommand extends ProjectionCommand +final class SubscriptionPauseCommand extends SubscriptionCommand { protected function execute(InputInterface $input, OutputInterface $output): int { - $criteria = $this->projectionCriteria($input); - $this->projectionist->pause($criteria); + $criteria = $this->subscriptionEngineCriteria($input); + $this->engine->pause($criteria); return 0; } diff --git a/src/Console/Command/ProjectionReactivateCommand.php b/src/Console/Command/SubscriptionReactivateCommand.php similarity index 59% rename from src/Console/Command/ProjectionReactivateCommand.php rename to src/Console/Command/SubscriptionReactivateCommand.php index da1aaa7ce..81ec2e1f0 100644 --- a/src/Console/Command/ProjectionReactivateCommand.php +++ b/src/Console/Command/SubscriptionReactivateCommand.php @@ -9,15 +9,15 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - 'event-sourcing:projection:reactivate', - 'Reactivate failed projections', + 'event-sourcing:subscription:reactivate', + 'Reactivate subscriptions', )] -final class ProjectionReactivateCommand extends ProjectionCommand +final class SubscriptionReactivateCommand extends SubscriptionCommand { protected function execute(InputInterface $input, OutputInterface $output): int { - $criteria = $this->projectionCriteria($input); - $this->projectionist->reactivate($criteria); + $criteria = $this->subscriptionEngineCriteria($input); + $this->engine->reactivate($criteria); return 0; } diff --git a/src/Console/Command/ProjectionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php similarity index 63% rename from src/Console/Command/ProjectionRemoveCommand.php rename to src/Console/Command/SubscriptionRemoveCommand.php index f08613283..dbff5177b 100644 --- a/src/Console/Command/ProjectionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -10,24 +10,24 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - 'event-sourcing:projection:remove', - 'Delete all projection and metadata', + 'event-sourcing:subscription:remove', + 'Delete all subscriptions', )] -final class ProjectionRemoveCommand extends ProjectionCommand +final class SubscriptionRemoveCommand extends SubscriptionCommand { protected function execute(InputInterface $input, OutputInterface $output): int { $io = new OutputStyle($input, $output); - $criteria = $this->projectionCriteria($input); + $criteria = $this->subscriptionEngineCriteria($input); if ($criteria->ids === null) { - if (!$io->confirm('do you want to remove all projections?', false)) { + if (!$io->confirm('do you want to remove all subscriptions?', false)) { return 1; } } - $this->projectionist->remove($criteria); + $this->engine->remove($criteria); return 0; } diff --git a/src/Console/Command/ProjectionRunCommand.php b/src/Console/Command/SubscriptionRunCommand.php similarity index 87% rename from src/Console/Command/ProjectionRunCommand.php rename to src/Console/Command/SubscriptionRunCommand.php index 1a6d79381..0ef42ec35 100644 --- a/src/Console/Command/ProjectionRunCommand.php +++ b/src/Console/Command/SubscriptionRunCommand.php @@ -13,10 +13,10 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - 'event-sourcing:projection:run', - 'Run the active projections', + 'event-sourcing:subscription:run', + 'Run the active subscriptions', )] -final class ProjectionRunCommand extends ProjectionCommand +final class SubscriptionRunCommand extends SubscriptionCommand { protected function configure(): void { @@ -59,7 +59,7 @@ protected function configure(): void 'rebuild', null, InputOption::VALUE_NONE, - 'rebuild (remove & boot) projections before run', + 'rebuild (remove & boot) subscriptions before run', ); } @@ -72,13 +72,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $sleep = InputHelper::positiveIntOrZero($input->getOption('sleep')); $rebuild = InputHelper::bool($input->getOption('rebuild')); - $criteria = $this->projectionCriteria($input); + $criteria = $this->subscriptionEngineCriteria($input); $logger = new ConsoleLogger($output); $worker = DefaultWorker::create( function () use ($criteria, $messageLimit): void { - $this->projectionist->run($criteria, $messageLimit); + $this->engine->run($criteria, $messageLimit); }, [ 'runLimit' => $runLimit, @@ -89,8 +89,8 @@ function () use ($criteria, $messageLimit): void { ); if ($rebuild) { - $this->projectionist->remove($criteria); - $this->projectionist->boot($criteria); + $this->engine->remove($criteria); + $this->engine->boot($criteria); } $worker->run($sleep); diff --git a/src/Console/Command/ProjectionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php similarity index 56% rename from src/Console/Command/ProjectionStatusCommand.php rename to src/Console/Command/SubscriptionStatusCommand.php index 52224ef7a..2d2169505 100644 --- a/src/Console/Command/ProjectionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -6,9 +6,9 @@ use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Console\OutputStyle; -use Patchlevel\EventSourcing\Projection\Projection\Projection; -use Patchlevel\EventSourcing\Projection\Projection\ProjectionError; -use Patchlevel\EventSourcing\Projection\Projection\ProjectionNotFound; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; +use Patchlevel\EventSourcing\Subscription\Subscription; +use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -18,12 +18,12 @@ use function is_array; use function sprintf; -/** @psalm-import-type Context from ProjectionError */ +/** @psalm-import-type Context from SubscriptionError */ #[AsCommand( - 'event-sourcing:projection:status', - 'View the current status of the projections', + 'event-sourcing:subscription:status', + 'View the current status of the subscriptions', )] -final class ProjectionStatusCommand extends ProjectionCommand +final class SubscriptionStatusCommand extends SubscriptionCommand { protected function configure(): void { @@ -32,7 +32,7 @@ protected function configure(): void $this->addArgument( 'id', InputArgument::OPTIONAL, - 'The projection to display more information about', + 'The subscription to display more information about', ); } @@ -41,7 +41,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $io = new OutputStyle($input, $output); $id = InputHelper::nullableString($input->getArgument('id')); - $projections = $this->projectionist->projections(); + $subscriptions = $this->engine->subscriptions(); if ($id === null) { $io->table( @@ -52,20 +52,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'error message', ], array_map( - static fn (Projection $projection) => [ - $projection->id(), - $projection->position(), - $projection->status()->value, - $projection->projectionError()?->errorMessage, + static fn (Subscription $subscription) => [ + $subscription->id(), + $subscription->position(), + $subscription->status()->value, + $subscription->subscriptionError()?->errorMessage, ], - $projections, + $subscriptions, ), ); return 0; } - $projection = $this->findProjection($projections, $id); + $subscription = $this->findSubscription($subscriptions, $id); $io->horizontalTable( [ @@ -76,15 +76,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int ], [ [ - $projection->id(), - $projection->position(), - $projection->status()->value, - $projection->projectionError()?->errorMessage, + $subscription->id(), + $subscription->position(), + $subscription->status()->value, + $subscription->subscriptionError()?->errorMessage, ], ], ); - $contexts = $projection->projectionError()?->errorContext; + $contexts = $subscription->subscriptionError()?->errorContext; if (is_array($contexts)) { foreach ($contexts as $context) { @@ -105,15 +105,15 @@ private function displayError(OutputStyle $io, array $context): void } } - /** @param list $projections */ - private function findProjection(array $projections, string $id): Projection + /** @param list $subscriptions */ + private function findSubscription(array $subscriptions, string $id): Subscription { - foreach ($projections as $projection) { - if ($projection->id() === $id) { - return $projection; + foreach ($subscriptions as $subscription) { + if ($subscription->id() === $id) { + return $subscription; } } - throw new ProjectionNotFound($id); + throw new SubscriptionNotFound($id); } } diff --git a/src/Console/Command/ProjectionTeardownCommand.php b/src/Console/Command/SubscriptionTeardownCommand.php similarity index 58% rename from src/Console/Command/ProjectionTeardownCommand.php rename to src/Console/Command/SubscriptionTeardownCommand.php index 34b990e43..448429a0b 100644 --- a/src/Console/Command/ProjectionTeardownCommand.php +++ b/src/Console/Command/SubscriptionTeardownCommand.php @@ -9,15 +9,15 @@ use Symfony\Component\Console\Output\OutputInterface; #[AsCommand( - 'event-sourcing:projection:teardown', - 'Shut down and delete the outdated projections', + 'event-sourcing:subscription:teardown', + 'Shut down and delete the outdated subscriptions', )] -final class ProjectionTeardownCommand extends ProjectionCommand +final class SubscriptionTeardownCommand extends SubscriptionCommand { protected function execute(InputInterface $input, OutputInterface $output): int { - $criteria = $this->projectionCriteria($input); - $this->projectionist->teardown($criteria); + $criteria = $this->subscriptionEngineCriteria($input); + $this->engine->teardown($criteria); return 0; } diff --git a/src/Metadata/Projector/DuplicateSetupMethod.php b/src/Metadata/Projector/DuplicateSetupMethod.php deleted file mode 100644 index c279145cb..000000000 --- a/src/Metadata/Projector/DuplicateSetupMethod.php +++ /dev/null @@ -1,25 +0,0 @@ -cache->get($projector); - - if ($metadata !== null) { - return $metadata; - } - - $metadata = $this->projectorMetadataFactory->metadata($projector); - - $this->cache->set($projector, $metadata); - - return $metadata; - } -} diff --git a/src/Metadata/Projector/Psr6ProjectorMetadataFactory.php b/src/Metadata/Projector/Psr6ProjectorMetadataFactory.php deleted file mode 100644 index 0146cd24f..000000000 --- a/src/Metadata/Projector/Psr6ProjectorMetadataFactory.php +++ /dev/null @@ -1,38 +0,0 @@ -cache->getItem($projector); - - if ($item->isHit()) { - $data = $item->get(); - assert($data instanceof ProjectorMetadata); - - return $data; - } - - $metadata = $this->projectorMetadataFactory->metadata($projector); - - $item->set($metadata); - $this->cache->save($item); - - return $metadata; - } -} diff --git a/src/Metadata/Projector/AttributeProjectorMetadataFactory.php b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php similarity index 60% rename from src/Metadata/Projector/AttributeProjectorMetadataFactory.php rename to src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php index 0a3bcd116..dc8822687 100644 --- a/src/Metadata/Projector/AttributeProjectorMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php @@ -2,37 +2,37 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Metadata\Projector; +namespace Patchlevel\EventSourcing\Metadata\Subscriber; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use ReflectionClass; use function array_key_exists; -final class AttributeProjectorMetadataFactory implements ProjectorMetadataFactory +final class AttributeSubscriberMetadataFactory implements SubscriberMetadataFactory { - /** @var array */ - private array $projectorMetadata = []; + /** @var array */ + private array $subscriberMetadata = []; - /** @param class-string $projector */ - public function metadata(string $projector): ProjectorMetadata + /** @param class-string $subscriber */ + public function metadata(string $subscriber): SubscriberMetadata { - if (array_key_exists($projector, $this->projectorMetadata)) { - return $this->projectorMetadata[$projector]; + if (array_key_exists($subscriber, $this->subscriberMetadata)) { + return $this->subscriberMetadata[$subscriber]; } - $reflector = new ReflectionClass($projector); + $reflector = new ReflectionClass($subscriber); - $attributes = $reflector->getAttributes(Projector::class); + $attributes = $reflector->getAttributes(Subscriber::class); if ($attributes === []) { - throw new ClassIsNotAProjector($projector); + throw new ClassIsNotASubscriber($subscriber); } - $projectorInfo = $attributes[0]->newInstance(); + $subscriberInfo = $attributes[0]->newInstance(); $methods = $reflector->getMethods(); @@ -53,7 +53,7 @@ public function metadata(string $projector): ProjectorMetadata if ($method->getAttributes(Setup::class)) { if ($createMethod !== null) { throw new DuplicateSetupMethod( - $projector, + $subscriber, $createMethod, $method->getName(), ); @@ -68,7 +68,7 @@ public function metadata(string $projector): ProjectorMetadata if ($dropMethod !== null) { throw new DuplicateTeardownMethod( - $projector, + $subscriber, $dropMethod, $method->getName(), ); @@ -77,16 +77,16 @@ public function metadata(string $projector): ProjectorMetadata $dropMethod = $method->getName(); } - $metadata = new ProjectorMetadata( - $projectorInfo->id, - $projectorInfo->group, - $projectorInfo->runMode, + $metadata = new SubscriberMetadata( + $subscriberInfo->id, + $subscriberInfo->group, + $subscriberInfo->runMode, $subscribeMethods, $createMethod, $dropMethod, ); - $this->projectorMetadata[$projector] = $metadata; + $this->subscriberMetadata[$subscriber] = $metadata; return $metadata; } diff --git a/src/Metadata/Projector/ClassIsNotAProjector.php b/src/Metadata/Subscriber/ClassIsNotASubscriber.php similarity index 65% rename from src/Metadata/Projector/ClassIsNotAProjector.php rename to src/Metadata/Subscriber/ClassIsNotASubscriber.php index feb9276ff..3078241a8 100644 --- a/src/Metadata/Projector/ClassIsNotAProjector.php +++ b/src/Metadata/Subscriber/ClassIsNotASubscriber.php @@ -2,20 +2,20 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Metadata\Projector; +namespace Patchlevel\EventSourcing\Metadata\Subscriber; use Patchlevel\EventSourcing\Metadata\MetadataException; use function sprintf; -final class ClassIsNotAProjector extends MetadataException +final class ClassIsNotASubscriber extends MetadataException { /** @param class-string $class */ public function __construct(string $class) { parent::__construct( sprintf( - 'Class "%s" is not a projector', + 'Class "%s" is not a subscriber', $class, ), ); diff --git a/src/Metadata/Subscriber/DuplicateSetupMethod.php b/src/Metadata/Subscriber/DuplicateSetupMethod.php new file mode 100644 index 000000000..c39f2005f --- /dev/null +++ b/src/Metadata/Subscriber/DuplicateSetupMethod.php @@ -0,0 +1,25 @@ +cache->get($subscriber); + + if ($metadata !== null) { + return $metadata; + } + + $metadata = $this->subscriberMetadataFactory->metadata($subscriber); + + $this->cache->set($subscriber, $metadata); + + return $metadata; + } +} diff --git a/src/Metadata/Subscriber/Psr6SubscriberMetadataFactory.php b/src/Metadata/Subscriber/Psr6SubscriberMetadataFactory.php new file mode 100644 index 000000000..305e689ec --- /dev/null +++ b/src/Metadata/Subscriber/Psr6SubscriberMetadataFactory.php @@ -0,0 +1,38 @@ +cache->getItem($subscriber); + + if ($item->isHit()) { + $data = $item->get(); + assert($data instanceof SubscriberMetadata); + + return $data; + } + + $metadata = $this->subscriberMetadataFactory->metadata($subscriber); + + $item->set($metadata); + $this->cache->save($item); + + return $metadata; + } +} diff --git a/src/Metadata/Projector/ProjectorMetadata.php b/src/Metadata/Subscriber/SubscriberMetadata.php similarity index 60% rename from src/Metadata/Projector/ProjectorMetadata.php rename to src/Metadata/Subscriber/SubscriberMetadata.php index 5d776f4cc..e6335d795 100644 --- a/src/Metadata/Projector/ProjectorMetadata.php +++ b/src/Metadata/Subscriber/SubscriberMetadata.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Metadata\Projector; +namespace Patchlevel\EventSourcing\Metadata\Subscriber; -use Patchlevel\EventSourcing\Projection\Projection\Projection; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; +use Patchlevel\EventSourcing\Subscription\RunMode; +use Patchlevel\EventSourcing\Subscription\Subscription; -final class ProjectorMetadata +final class SubscriberMetadata { public function __construct( public readonly string $id, - public readonly string $group = Projection::DEFAULT_GROUP, + public readonly string $group = Subscription::DEFAULT_GROUP, public readonly RunMode $runMode = RunMode::FromBeginning, /** @var array> */ public readonly array $subscribeMethods = [], diff --git a/src/Metadata/Subscriber/SubscriberMetadataFactory.php b/src/Metadata/Subscriber/SubscriberMetadataFactory.php new file mode 100644 index 000000000..db174d35e --- /dev/null +++ b/src/Metadata/Subscriber/SubscriberMetadataFactory.php @@ -0,0 +1,11 @@ + */ - private array $projections = []; - - /** @param list $projections */ - public function __construct(array $projections = []) - { - foreach ($projections as $projection) { - $this->projections[$projection->id()] = $projection; - } - } - - public function get(string $projectionId): Projection - { - if (array_key_exists($projectionId, $this->projections)) { - return $this->projections[$projectionId]; - } - - throw new ProjectionNotFound($projectionId); - } - - /** @return list */ - public function find(ProjectionCriteria|null $criteria = null): array - { - $projections = array_values($this->projections); - - if ($criteria === null) { - return $projections; - } - - return array_values( - array_filter( - $projections, - static function (Projection $projection) use ($criteria): bool { - if ($criteria->ids !== null) { - if (!in_array($projection->id(), $criteria->ids, true)) { - return false; - } - } - - if ($criteria->groups !== null) { - if (!in_array($projection->group(), $criteria->groups, true)) { - return false; - } - } - - if ($criteria->status !== null) { - if (!in_array($projection->status(), $criteria->status, true)) { - return false; - } - } - - return true; - }, - ), - ); - } - - public function add(Projection $projection): void - { - if (array_key_exists($projection->id(), $this->projections)) { - throw new ProjectionAlreadyExists($projection->id()); - } - - $this->projections[$projection->id()] = $projection; - } - - public function update(Projection $projection): void - { - if (!array_key_exists($projection->id(), $this->projections)) { - throw new ProjectionNotFound($projection->id()); - } - - $this->projections[$projection->id()] = $projection; - } - - public function remove(Projection $projection): void - { - unset($this->projections[$projection->id()]); - } -} diff --git a/src/Projection/Projection/Store/LockableProjectionStore.php b/src/Projection/Projection/Store/LockableProjectionStore.php deleted file mode 100644 index a8e3d23d0..000000000 --- a/src/Projection/Projection/Store/LockableProjectionStore.php +++ /dev/null @@ -1,12 +0,0 @@ - */ - public function find(ProjectionCriteria|null $criteria = null): array; - - /** @throws ProjectionAlreadyExists */ - public function add(Projection $projection): void; - - /** @throws ProjectionNotFound */ - public function update(Projection $projection): void; - - /** @throws ProjectionNotFound */ - public function remove(Projection $projection): void; -} diff --git a/src/Projection/Projectionist/DefaultProjectionist.php b/src/Projection/Projectionist/DefaultProjectionist.php deleted file mode 100644 index 0f3e905c5..000000000 --- a/src/Projection/Projectionist/DefaultProjectionist.php +++ /dev/null @@ -1,868 +0,0 @@ -logger?->info( - 'Projectionist: Start booting.', - ); - - $this->discoverNewProjections(); - $this->handleRetryProjections($criteria); - $this->handleNewProjections($criteria); - - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ProjectionStatus::Booting], - ), - function ($projections) use ($limit): void { - $projections = $this->fastForwardFromNowProjections($projections); - - if (count($projections) === 0) { - $this->logger?->info('Projectionist: No projections in booting status, finish booting.'); - - return; - } - - $startIndex = $this->lowestProjectionPosition($projections); - - $this->logger?->debug( - sprintf( - 'Projectionist: Event stream is processed for booting from position %s.', - $startIndex, - ), - ); - - $stream = null; - $messageCounter = 0; - - try { - $stream = $this->messageStore->load( - new Criteria(fromIndex: $startIndex), - ); - - foreach ($stream as $message) { - $index = $stream->index(); - - if ($index === null) { - throw new UnexpectedError('Stream index is null, this should not happen.'); - } - - foreach ($projections as $projection) { - if (!$projection->isBooting()) { - continue; - } - - if ($projection->position() >= $index) { - $this->logger?->debug( - sprintf( - 'Projectionist: Projection "%s" is farther than the current position (%d > %d), continue booting.', - $projection->id(), - $projection->position(), - $index, - ), - ); - - continue; - } - - $this->handleMessage($index, $message, $projection); - } - - $messageCounter++; - - $this->logger?->debug( - sprintf( - 'Projectionist: Current event stream position for booting: %s', - $index, - ), - ); - - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info( - sprintf( - 'Projectionist: Message limit (%d) reached, finish booting.', - $limit, - ), - ); - - return; - } - } - } finally { - if ($messageCounter > 0) { - foreach ($projections as $projection) { - if (!$projection->isBooting()) { - continue; - } - - $this->projectionStore->update($projection); - } - } - - $stream?->close(); - } - - $this->logger?->debug('Projectionist: End of stream for booting has been reached.'); - - foreach ($projections as $projection) { - if (!$projection->isBooting()) { - continue; - } - - if ($projection->runMode() === RunMode::Once) { - $projection->finished(); - $this->projectionStore->update($projection); - - $this->logger?->info(sprintf( - 'Projectionist: Projection "%s" run only once and has been set to finished.', - $projection->id(), - )); - - continue; - } - - $projection->active(); - $this->projectionStore->update($projection); - - $this->logger?->info(sprintf( - 'Projectionist: Projection "%s" has been set to active after booting.', - $projection->id(), - )); - } - - $this->logger?->info('Projectionist: Finish booting.'); - }, - ); - } - - public function run( - ProjectionistCriteria|null $criteria = null, - int|null $limit = null, - ): void { - $criteria ??= new ProjectionistCriteria(); - - $this->logger?->info('Projectionist: Start processing.'); - - $this->discoverNewProjections(); - $this->handleOutdatedProjections($criteria); - $this->handleRetryProjections($criteria); - - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ProjectionStatus::Active], - ), - function (array $projections) use ($limit): void { - if (count($projections) === 0) { - $this->logger?->info('Projectionist: No projections to process, finish processing.'); - - return; - } - - $startIndex = $this->lowestProjectionPosition($projections); - - $this->logger?->debug( - sprintf( - 'Projectionist: Event stream is processed from position %d.', - $startIndex, - ), - ); - - $stream = null; - $messageCounter = 0; - - try { - $criteria = new Criteria(fromIndex: $startIndex); - $stream = $this->messageStore->load($criteria); - - foreach ($stream as $message) { - $index = $stream->index(); - - if ($index === null) { - throw new UnexpectedError('Stream index is null, this should not happen.'); - } - - foreach ($projections as $projection) { - if (!$projection->isActive()) { - continue; - } - - if ($projection->position() >= $index) { - $this->logger?->debug( - sprintf( - 'Projectionist: Projection "%s" is farther than the current position (%d > %d), continue processing.', - $projection->id(), - $projection->position(), - $index, - ), - ); - - continue; - } - - $this->handleMessage($index, $message, $projection); - } - - $messageCounter++; - - $this->logger?->debug(sprintf('Projectionist: Current event stream position: %s', $index)); - - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info( - sprintf( - 'Projectionist: Message limit (%d) reached, finish processing.', - $limit, - ), - ); - - return; - } - } - } finally { - if ($messageCounter > 0) { - foreach ($projections as $projection) { - if (!$projection->isActive()) { - continue; - } - - $this->projectionStore->update($projection); - } - } - - $stream?->close(); - } - - $this->logger?->info( - sprintf( - 'Projectionist: End of stream on position "%d" has been reached, finish processing.', - $stream->index() ?: 'unknown', - ), - ); - }, - ); - } - - public function teardown(ProjectionistCriteria|null $criteria = null): void - { - $criteria ??= new ProjectionistCriteria(); - - $this->discoverNewProjections(); - - $this->logger?->info('Projectionist: Start teardown outdated projections.'); - - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ProjectionStatus::Outdated], - ), - function (array $projections): void { - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if (!$projector) { - $this->logger?->warning( - sprintf( - 'Projectionist: Projector for "%s" to teardown not found, skipped.', - $projection->id(), - ), - ); - - continue; - } - - $teardownMethod = $projector->teardownMethod(); - - if (!$teardownMethod) { - $this->projectionStore->remove($projection); - - $this->logger?->info( - sprintf( - 'Projectionist: Projector "%s" for "%s" has no teardown method and was immediately removed.', - $projector::class, - $projection->id(), - ), - ); - - continue; - } - - try { - $teardownMethod(); - - $this->logger?->debug(sprintf( - 'Projectionist: For Projector "%s" for "%s" the teardown method has been executed and is now prepared to be removed.', - $projector::class, - $projection->id(), - )); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Projectionist: Projection "%s" for "%s" has an error in the teardown method, skipped: %s', - $projector::class, - $projection->id(), - $e->getMessage(), - ), - ); - continue; - } - - $this->projectionStore->remove($projection); - - $this->logger?->info( - sprintf( - 'Projectionist: Projection "%s" removed.', - $projection->id(), - ), - ); - } - - $this->logger?->info('Projectionist: Finish teardown.'); - }, - ); - } - - public function remove(ProjectionistCriteria|null $criteria = null): void - { - $criteria ??= new ProjectionistCriteria(); - - $this->discoverNewProjections(); - - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - ), - function (array $projections): void { - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if (!$projector) { - $this->projectionStore->remove($projection); - - $this->logger?->info( - sprintf( - 'Projectionist: Projection "%s" removed without a suitable projector.', - $projection->id(), - ), - ); - - continue; - } - - $teardownMethod = $projector->teardownMethod(); - - if (!$teardownMethod) { - $this->projectionStore->remove($projection); - - $this->logger?->info( - sprintf('Projectionist: Projection "%s" removed.', $projection->id()), - ); - - continue; - } - - try { - $teardownMethod(); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Projectionist: Projector "%s" teardown method could not be executed: %s', - $projector::class, - $e->getMessage(), - ), - ); - } - - $this->projectionStore->remove($projection); - - $this->logger?->info( - sprintf('Projectionist: Projection "%s" removed.', $projection->id()), - ); - } - }, - ); - } - - public function reactivate(ProjectionistCriteria|null $criteria = null): void - { - $criteria ??= new ProjectionistCriteria(); - - $this->discoverNewProjections(); - - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ - ProjectionStatus::Error, - ProjectionStatus::Outdated, - ProjectionStatus::Paused, - ProjectionStatus::Finished, - ], - ), - function (array $projections): void { - /** @var Projection $projection */ - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if (!$projector) { - $this->logger?->debug( - sprintf('Projectionist: Projector for "%s" not found, skipped.', $projection->id()), - ); - - continue; - } - - $error = $projection->projectionError(); - - if ($error) { - $projection->doRetry(); - $projection->resetRetry(); - - $this->projectionStore->update($projection); - - $this->logger?->info(sprintf( - 'Projectionist: Projector "%s" for "%s" is reactivated.', - $projector::class, - $projection->id(), - )); - - continue; - } - - $projection->active(); - $this->projectionStore->update($projection); - - $this->logger?->info(sprintf( - 'Projectionist: Projector "%s" for "%s" is reactivated.', - $projector::class, - $projection->id(), - )); - } - }, - ); - } - - public function pause(ProjectionistCriteria|null $criteria = null): void - { - $criteria ??= new ProjectionistCriteria(); - - $this->discoverNewProjections(); - - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ - ProjectionStatus::Active, - ProjectionStatus::Booting, - ProjectionStatus::Error, - ], - ), - function (array $projections): void { - /** @var Projection $projection */ - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if (!$projector) { - $this->logger?->debug( - sprintf('Projectionist: Projector for "%s" not found, skipped.', $projection->id()), - ); - - continue; - } - - $projection->pause(); - $this->projectionStore->update($projection); - - $this->logger?->info(sprintf( - 'Projectionist: Projector "%s" for "%s" is paused.', - $projector::class, - $projection->id(), - )); - } - }, - ); - } - - /** @return list */ - public function projections(ProjectionistCriteria|null $criteria = null): array - { - $criteria ??= new ProjectionistCriteria(); - - $this->discoverNewProjections(); - - return $this->projectionStore->find( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - ), - ); - } - - private function handleMessage(int $index, Message $message, Projection $projection): void - { - $projector = $this->projector($projection->id()); - - if (!$projector) { - throw ProjectorNotFound::forProjectionId($projection->id()); - } - - $subscribeMethods = $projector->subscribeMethods($message->event()::class); - - if ($subscribeMethods === []) { - $projection->changePosition($index); - - $this->logger?->debug( - sprintf( - 'Projectionist: Projector "%s" for "%s" has no subscribe methods for "%s", continue.', - $projector::class, - $projection->id(), - $message->event()::class, - ), - ); - - return; - } - - try { - foreach ($subscribeMethods as $subscribeMethod) { - $subscribeMethod($message); - } - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Projectionist: Projector "%s" for "%s" could not process the event "%s": %s', - $projector::class, - $projection->id(), - $message->event()::class, - $e->getMessage(), - ), - ); - - $this->handleError($projection, $e); - - return; - } - - $projection->changePosition($index); - $projection->resetRetry(); - - $this->logger?->debug( - sprintf( - 'Projectionist: Projector "%s" for "%s" processed the event "%s".', - $projector::class, - $projection->id(), - $message->event()::class, - ), - ); - } - - private function projector(string $projectionId): ProjectorAccessor|null - { - return $this->projectorRepository->get($projectionId); - } - - private function handleOutdatedProjections(ProjectionistCriteria $criteria): void - { - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ProjectionStatus::Active, ProjectionStatus::Paused, ProjectionStatus::Finished], - ), - function (array $projections): void { - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if ($projector) { - continue; - } - - $projection->outdated(); - $this->projectionStore->update($projection); - - $this->logger?->info( - sprintf( - 'Projectionist: Projector for "%s" not found and has been marked as outdated.', - $projection->id(), - ), - ); - } - }, - ); - } - - private function handleRetryProjections(ProjectionistCriteria $criteria): void - { - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ProjectionStatus::Error], - ), - function (array $projections): void { - /** @var Projection $projection */ - foreach ($projections as $projection) { - $error = $projection->projectionError(); - - if ($error === null) { - continue; - } - - $retryable = in_array( - $error->previousStatus, - [ProjectionStatus::New, ProjectionStatus::Booting, ProjectionStatus::Active], - true, - ); - - if (!$retryable) { - continue; - } - - if (!$this->retryStrategy->shouldRetry($projection)) { - continue; - } - - $projection->doRetry(); - $this->projectionStore->update($projection); - - $this->logger?->info( - sprintf( - 'Projectionist: Retry projection "%s" (%d) and set back to %s.', - $projection->id(), - $projection->retryAttempt(), - $projection->status()->value, - ), - ); - } - }, - ); - } - - /** - * @param list $projections - * - * @return list - */ - private function fastForwardFromNowProjections(array $projections): array - { - $latestIndex = null; - $forwardedProjections = []; - - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if (!$projector) { - $forwardedProjections[] = $projection; - - continue; - } - - if ($projection->runMode() === RunMode::FromBeginning || $projection->runMode() === RunMode::Once) { - $forwardedProjections[] = $projection; - - continue; - } - - if ($latestIndex === null) { - $latestIndex = $this->latestIndex(); - } - - $projection->changePosition($latestIndex); - $projection->active(); - $this->projectionStore->update($projection); - - $this->logger?->info( - sprintf( - 'Projectionist: Projector "%s" for "%s" is in "from now" mode: skip past messages and set to active.', - $projector::class, - $projection->id(), - ), - ); - } - - return $forwardedProjections; - } - - private function handleNewProjections(ProjectionistCriteria $criteria): void - { - $this->findForUpdate( - new ProjectionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ProjectionStatus::New], - ), - function (array $projections): void { - foreach ($projections as $projection) { - $projector = $this->projector($projection->id()); - - if (!$projector) { - throw ProjectorNotFound::forProjectionId($projection->id()); - } - - $setupMethod = $projector->setupMethod(); - - if (!$setupMethod) { - $projection->booting(); - $this->projectionStore->update($projection); - - $this->logger?->debug(sprintf( - 'Projectionist: Projector "%s" for "%s" has no setup method, continue.', - $projector::class, - $projection->id(), - )); - - continue; - } - - try { - $setupMethod(); - - $projection->booting(); - $this->projectionStore->update($projection); - - $this->logger?->debug(sprintf( - 'Projectionist: For Projector "%s" for "%s" the setup method has been executed and is now prepared for data.', - $projector::class, - $projection->id(), - )); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Projectionist: Projector "%s" for "%s" has an error in the setup method: %s', - $projector::class, - $projection->id(), - $e->getMessage(), - )); - - $this->handleError($projection, $e); - } - } - }, - ); - } - - private function discoverNewProjections(): void - { - $this->findForUpdate( - new ProjectionCriteria(), - function (array $projections): void { - foreach ($this->projectorRepository->all() as $projector) { - foreach ($projections as $projection) { - if ($projection->id() === $projector->id()) { - continue 2; - } - } - - $this->projectionStore->add( - new Projection( - $projector->id(), - $projector->group(), - $projector->runMode(), - ), - ); - - $this->logger?->info( - sprintf( - 'Projectionist: New Projector "%s" was found and added to the projection store.', - $projector->id(), - ), - ); - } - }, - ); - } - - private function latestIndex(): int - { - $stream = $this->messageStore->load(null, 1, null, true); - - return $stream->index() ?: 0; - } - - /** @param list $projections */ - private function lowestProjectionPosition(array $projections): int - { - $min = null; - - foreach ($projections as $projection) { - if ($min !== null && $projection->position() >= $min) { - continue; - } - - $min = $projection->position(); - } - - if ($min === null) { - return 0; - } - - return $min; - } - - /** @param Closure(list):void $closure */ - private function findForUpdate(ProjectionCriteria $criteria, Closure $closure): void - { - if (!$this->projectionStore instanceof LockableProjectionStore) { - $closure($this->projectionStore->find($criteria)); - - return; - } - - $this->projectionStore->inLock(function () use ($closure, $criteria): void { - $projections = $this->projectionStore->find($criteria); - - $closure($projections); - }); - } - - private function handleError(Projection $projection, Throwable $throwable): void - { - $projection->error($throwable); - $this->projectionStore->update($projection); - } -} diff --git a/src/Projection/Projectionist/Projectionist.php b/src/Projection/Projectionist/Projectionist.php deleted file mode 100644 index 7ad6e75bb..000000000 --- a/src/Projection/Projectionist/Projectionist.php +++ /dev/null @@ -1,41 +0,0 @@ - */ - public function projections(ProjectionistCriteria|null $criteria = null): array; -} diff --git a/src/Projection/Projectionist/ProjectorNotFound.php b/src/Projection/Projectionist/ProjectorNotFound.php deleted file mode 100644 index f2af3622d..000000000 --- a/src/Projection/Projectionist/ProjectorNotFound.php +++ /dev/null @@ -1,22 +0,0 @@ - */ - private array $projectorsMap = []; - - /** @param iterable $projectors */ - public function __construct( - private readonly iterable $projectors, - private readonly ProjectorMetadataFactory $metadataFactory = new AttributeProjectorMetadataFactory(), - ) { - } - - /** @return iterable */ - public function all(): iterable - { - return array_values($this->projectorAccessorMap()); - } - - public function get(string $id): ProjectorAccessor|null - { - $map = $this->projectorAccessorMap(); - - return $map[$id] ?? null; - } - - /** @return array */ - private function projectorAccessorMap(): array - { - if ($this->projectorsMap !== []) { - return $this->projectorsMap; - } - - foreach ($this->projectors as $projector) { - $metadata = $this->metadataFactory->metadata($projector::class); - $this->projectorsMap[$metadata->id] = new MetadataProjectorAccessor($projector, $metadata); - } - - return $this->projectorsMap; - } -} diff --git a/src/Projection/Projector/ProjectorAccessorRepository.php b/src/Projection/Projector/ProjectorAccessorRepository.php deleted file mode 100644 index 267a97e51..000000000 --- a/src/Projection/Projector/ProjectorAccessorRepository.php +++ /dev/null @@ -1,13 +0,0 @@ - */ - public function all(): iterable; - - public function get(string $id): ProjectorAccessor|null; -} diff --git a/src/Projection/Projector/ProjectorHelper.php b/src/Projection/Projector/ProjectorHelper.php deleted file mode 100644 index 5ca501d51..000000000 --- a/src/Projection/Projector/ProjectorHelper.php +++ /dev/null @@ -1,27 +0,0 @@ -getProjectorMetadata($projector)->id; - } - - private function getProjectorMetadata(object $projector): ProjectorMetadata - { - return $this->metadataFactory->metadata($projector::class); - } -} diff --git a/src/Projection/Projector/ProjectorUtil.php b/src/Projection/Projector/ProjectorUtil.php deleted file mode 100644 index ea27f821f..000000000 --- a/src/Projection/Projector/ProjectorUtil.php +++ /dev/null @@ -1,37 +0,0 @@ -getProjectorHelper()->projectorId($this); - } -} diff --git a/src/Projection/Projector/TraceableProjectorAccessorRepository.php b/src/Projection/Projector/TraceableProjectorAccessorRepository.php deleted file mode 100644 index cb5beb789..000000000 --- a/src/Projection/Projector/TraceableProjectorAccessorRepository.php +++ /dev/null @@ -1,52 +0,0 @@ - */ - private array $projectorsMap = []; - - public function __construct( - private readonly ProjectorAccessorRepository $parent, - private readonly TraceStack $traceStack, - ) { - } - - /** @return iterable */ - public function all(): iterable - { - return array_values($this->projectorAccessorMap()); - } - - public function get(string $id): TraceableProjectorAccessor|null - { - $map = $this->projectorAccessorMap(); - - return $map[$id] ?? null; - } - - /** @return array */ - private function projectorAccessorMap(): array - { - if ($this->projectorsMap !== []) { - return $this->projectorsMap; - } - - foreach ($this->parent->all() as $projectorAccessor) { - $this->projectorsMap[$projectorAccessor->id()] = new TraceableProjectorAccessor( - $projectorAccessor, - $this->traceStack, - ); - } - - return $this->projectorsMap; - } -} diff --git a/src/Projection/RetryStrategy/NoRetryStrategy.php b/src/Projection/RetryStrategy/NoRetryStrategy.php deleted file mode 100644 index a9baea3ac..000000000 --- a/src/Projection/RetryStrategy/NoRetryStrategy.php +++ /dev/null @@ -1,15 +0,0 @@ -logger?->info( + 'Subscription Engine: Start booting.', + ); + + $this->discoverNewSubscriptions(); + $this->retrySubscriptions($criteria); + $this->setupNewSubscriptions($criteria); + + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [Status::Booting], + ), + function ($subscriptions) use ($limit): void { + $subscriptions = $this->fastForwardFromNowSubscriptions($subscriptions); + + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions in booting status, finish booting.'); + + return; + } + + $startIndex = $this->lowestSubscriptionPosition($subscriptions); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Event stream is processed for booting from position %s.', + $startIndex, + ), + ); + + $stream = null; + $messageCounter = 0; + + try { + $stream = $this->messageStore->load( + new Criteria(fromIndex: $startIndex), + ); + + foreach ($stream as $message) { + $index = $stream->index(); + + if ($index === null) { + throw new UnexpectedError('Stream index is null, this should not happen.'); + } + + foreach ($subscriptions as $subscription) { + if (!$subscription->isBooting()) { + continue; + } + + 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; + } + + $this->handleMessage($index, $message, $subscription); + } + + $messageCounter++; + + $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; + } + } + } finally { + if ($messageCounter > 0) { + foreach ($subscriptions as $subscription) { + if (!$subscription->isBooting()) { + continue; + } + + $this->subscriptionStore->update($subscription); + } + } + + $stream?->close(); + } + + $this->logger?->debug('Subscription Engine: End of stream for booting has been reached.'); + + foreach ($subscriptions as $subscription) { + if (!$subscription->isBooting()) { + continue; + } + + if ($subscription->runMode() === RunMode::Once) { + $subscription->finished(); + $this->subscriptionStore->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->subscriptionStore->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.'); + }, + ); + } + + public function run( + SubscriptionEngineCriteria|null $criteria = null, + int|null $limit = null, + ): void { + $criteria ??= new SubscriptionEngineCriteria(); + + $this->logger?->info('Subscription Engine: Start processing.'); + + $this->discoverNewSubscriptions(); + $this->markOutdatedSubscriptions($criteria); + $this->retrySubscriptions($criteria); + + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [Status::Active], + ), + function (array $subscriptions) use ($limit): void { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions to process, finish processing.'); + + return; + } + + $startIndex = $this->lowestSubscriptionPosition($subscriptions); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Event stream is processed from position %d.', + $startIndex, + ), + ); + + $stream = null; + $messageCounter = 0; + + try { + $criteria = new Criteria(fromIndex: $startIndex); + $stream = $this->messageStore->load($criteria); + + foreach ($stream as $message) { + $index = $stream->index(); + + if ($index === null) { + throw new UnexpectedError('Stream index is null, this should not happen.'); + } + + foreach ($subscriptions as $subscription) { + if (!$subscription->isActive()) { + continue; + } + + 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; + } + + $this->handleMessage($index, $message, $subscription); + } + + $messageCounter++; + + $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; + } + } + } finally { + if ($messageCounter > 0) { + foreach ($subscriptions as $subscription) { + if (!$subscription->isActive()) { + continue; + } + + $this->subscriptionStore->update($subscription); + } + } + + $stream?->close(); + } + + $this->logger?->info( + sprintf( + 'Subscription Engine: End of stream on position "%d" has been reached, finish processing.', + $stream->index() ?: 'unknown', + ), + ); + }, + ); + } + + public function teardown(SubscriptionEngineCriteria|null $criteria = null): void + { + $criteria ??= new SubscriptionEngineCriteria(); + + $this->discoverNewSubscriptions(); + + $this->logger?->info('Subscription Engine: Start teardown outdated subscriptions.'); + + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [Status::Outdated], + ), + function (array $subscriptions): void { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriber($subscription->id()); + + if (!$subscriber) { + $this->logger?->warning( + sprintf( + 'Subscription Engine: Subscriber for "%s" to teardown not found, skipped.', + $subscription->id(), + ), + ); + + continue; + } + + $teardownMethod = $subscriber->teardownMethod(); + + if (!$teardownMethod) { + $this->subscriptionStore->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(), + ), + ); + continue; + } + + $this->subscriptionStore->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + ), + ); + } + + $this->logger?->info('Subscription Engine: Finish teardown.'); + }, + ); + } + + public function remove(SubscriptionEngineCriteria|null $criteria = null): void + { + $criteria ??= new SubscriptionEngineCriteria(); + + $this->discoverNewSubscriptions(); + + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + ), + function (array $subscriptions): void { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriber($subscription->id()); + + if (!$subscriber) { + $this->subscriptionStore->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed without a suitable subscriber.', + $subscription->id(), + ), + ); + + continue; + } + + $teardownMethod = $subscriber->teardownMethod(); + + if (!$teardownMethod) { + $this->subscriptionStore->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(), + ), + ); + } + + $this->subscriptionStore->remove($subscription); + + $this->logger?->info( + sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), + ); + } + }, + ); + } + + public function reactivate(SubscriptionEngineCriteria|null $criteria = null): void + { + $criteria ??= new SubscriptionEngineCriteria(); + + $this->discoverNewSubscriptions(); + + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [ + Status::Error, + Status::Outdated, + Status::Paused, + Status::Finished, + ], + ), + function (array $subscriptions): void { + /** @var Subscription $subscription */ + 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->subscriptionStore->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); + + continue; + } + + $subscription->active(); + $this->subscriptionStore->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); + } + }, + ); + } + + public function pause(SubscriptionEngineCriteria|null $criteria = null): void + { + $criteria ??= new SubscriptionEngineCriteria(); + + $this->discoverNewSubscriptions(); + + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [ + Status::Active, + Status::Booting, + Status::Error, + ], + ), + function (array $subscriptions): void { + /** @var Subscription $subscription */ + 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; + } + + $subscription->pause(); + $this->subscriptionStore->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is paused.', + $subscriber::class, + $subscription->id(), + )); + } + }, + ); + } + + /** @return list */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array + { + $criteria ??= new SubscriptionEngineCriteria(); + + $this->discoverNewSubscriptions(); + + return $this->subscriptionStore->find( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + ), + ); + } + + private function handleMessage(int $index, Message $message, Subscription $subscription): void + { + $subscriber = $this->subscriber($subscription->id()); + + if (!$subscriber) { + throw SubscriberNotFound::forSubscriptionId($subscription->id()); + } + + $subscribeMethods = $subscriber->subscribeMethods($message->event()::class); + + if ($subscribeMethods === []) { + $subscription->changePosition($index); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no subscribe methods for "%s", continue.', + $subscriber::class, + $subscription->id(), + $message->event()::class, + ), + ); + + return; + } + + 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->handleError($subscription, $e); + + return; + } + + $subscription->changePosition($index); + $subscription->resetRetry(); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', + $subscriber::class, + $subscription->id(), + $message->event()::class, + ), + ); + } + + private function subscriber(string $subscriberId): SubscriberAccessor|null + { + return $this->subscriberRepository->get($subscriberId); + } + + private function markOutdatedSubscriptions(SubscriptionEngineCriteria $criteria): void + { + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [Status::Active, Status::Paused, Status::Finished], + ), + function (array $subscriptions): void { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriber($subscription->id()); + + if ($subscriber) { + continue; + } + + $subscription->outdated(); + $this->subscriptionStore->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found and has been marked as outdated.', + $subscription->id(), + ), + ); + } + }, + ); + } + + private function retrySubscriptions(SubscriptionEngineCriteria $criteria): void + { + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [Status::Error], + ), + function (array $subscriptions): void { + /** @var Subscription $subscription */ + foreach ($subscriptions as $subscription) { + $error = $subscription->subscriptionError(); + + if ($error === null) { + continue; + } + + $retryable = in_array( + $error->previousStatus, + [Status::New, Status::Booting, Status::Active], + true, + ); + + if (!$retryable) { + continue; + } + + if (!$this->retryStrategy->shouldRetry($subscription)) { + continue; + } + + $subscription->doRetry(); + $this->subscriptionStore->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Retry subscription "%s" (%d) and set back to %s.', + $subscription->id(), + $subscription->retryAttempt(), + $subscription->status()->value, + ), + ); + } + }, + ); + } + + /** + * @param list $subscriptions + * + * @return list + */ + private function fastForwardFromNowSubscriptions(array $subscriptions): array + { + $latestIndex = null; + $forwardedSubscriptions = []; + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriber($subscription->id()); + + if (!$subscriber) { + $forwardedSubscriptions[] = $subscription; + + continue; + } + + if ($subscription->runMode() === RunMode::FromBeginning || $subscription->runMode() === RunMode::Once) { + $forwardedSubscriptions[] = $subscription; + + continue; + } + + if ($latestIndex === null) { + $latestIndex = $this->latestIndex(); + } + + $subscription->changePosition($latestIndex); + $subscription->active(); + $this->subscriptionStore->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is in "from now" mode: skip past messages and set to active.', + $subscriber::class, + $subscription->id(), + ), + ); + } + + return $forwardedSubscriptions; + } + + private function setupNewSubscriptions(SubscriptionEngineCriteria $criteria): void + { + $this->findForUpdate( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: [Status::New], + ), + function (array $subscriptions): void { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriber($subscription->id()); + + if (!$subscriber) { + throw SubscriberNotFound::forSubscriptionId($subscription->id()); + } + + $setupMethod = $subscriber->setupMethod(); + + if (!$setupMethod) { + $subscription->booting(); + $this->subscriptionStore->update($subscription); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no setup method, continue.', + $subscriber::class, + $subscription->id(), + )); + + continue; + } + + try { + $setupMethod(); + + $subscription->booting(); + $this->subscriptionStore->update($subscription); + + $this->logger?->debug(sprintf( + 'Subscription Engine: For Subscriber "%s" for "%s" the setup method has been executed and is now prepared for data.', + $subscriber::class, + $subscription->id(), + )); + } 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); + } + } + }, + ); + } + + private function discoverNewSubscriptions(): void + { + $this->findForUpdate( + new SubscriptionCriteria(), + function (array $subscriptions): void { + foreach ($this->subscriberRepository->all() as $subscriber) { + foreach ($subscriptions as $subscription) { + if ($subscription->id() === $subscriber->id()) { + continue 2; + } + } + + $this->subscriptionStore->add( + new Subscription( + $subscriber->id(), + $subscriber->group(), + $subscriber->runMode(), + ), + ); + + $this->logger?->info( + sprintf( + 'Subscription Engine: New Subscriber "%s" was found and added to the subscription store.', + $subscriber->id(), + ), + ); + } + }, + ); + } + + private function latestIndex(): int + { + $stream = $this->messageStore->load(null, 1, null, true); + + return $stream->index() ?: 0; + } + + /** @param list $subscriptions */ + private function lowestSubscriptionPosition(array $subscriptions): int + { + $min = null; + + foreach ($subscriptions as $subscription) { + if ($min !== null && $subscription->position() >= $min) { + continue; + } + + $min = $subscription->position(); + } + + if ($min === null) { + return 0; + } + + return $min; + } + + /** @param Closure(list):void $closure */ + private function findForUpdate(SubscriptionCriteria $criteria, Closure $closure): void + { + if (!$this->subscriptionStore instanceof LockableSubscriptionStore) { + $closure($this->subscriptionStore->find($criteria)); + + return; + } + + $this->subscriptionStore->inLock(function () use ($closure, $criteria): void { + $subscriptions = $this->subscriptionStore->find($criteria); + + $closure($subscriptions); + }); + } + + private function handleError(Subscription $subscription, Throwable $throwable): void + { + $subscription->error($throwable); + $this->subscriptionStore->update($subscription); + } +} diff --git a/src/Subscription/Engine/SubscriberNotFound.php b/src/Subscription/Engine/SubscriberNotFound.php new file mode 100644 index 000000000..7e9958a99 --- /dev/null +++ b/src/Subscription/Engine/SubscriberNotFound.php @@ -0,0 +1,22 @@ + */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array; +} diff --git a/src/Projection/Projectionist/ProjectionistCriteria.php b/src/Subscription/Engine/SubscriptionEngineCriteria.php similarity index 76% rename from src/Projection/Projectionist/ProjectionistCriteria.php rename to src/Subscription/Engine/SubscriptionEngineCriteria.php index 538acb120..264624470 100644 --- a/src/Projection/Projectionist/ProjectionistCriteria.php +++ b/src/Subscription/Engine/SubscriptionEngineCriteria.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projectionist; +namespace Patchlevel\EventSourcing\Subscription\Engine; /** @psalm-immutable */ -final class ProjectionistCriteria +final class SubscriptionEngineCriteria { /** * @param list|null $ids diff --git a/src/Projection/Projectionist/UnexpectedError.php b/src/Subscription/Engine/UnexpectedError.php similarity index 65% rename from src/Projection/Projectionist/UnexpectedError.php rename to src/Subscription/Engine/UnexpectedError.php index 5a650802e..514efbb39 100644 --- a/src/Projection/Projectionist/UnexpectedError.php +++ b/src/Subscription/Engine/UnexpectedError.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projectionist; +namespace Patchlevel\EventSourcing\Subscription\Engine; use RuntimeException; diff --git a/src/Projection/Projection/NoErrorToRetry.php b/src/Subscription/NoErrorToRetry.php similarity index 59% rename from src/Projection/Projection/NoErrorToRetry.php rename to src/Subscription/NoErrorToRetry.php index 2ff10b192..61e4bb82c 100644 --- a/src/Projection/Projection/NoErrorToRetry.php +++ b/src/Subscription/NoErrorToRetry.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projection; +namespace Patchlevel\EventSourcing\Subscription; use RuntimeException; @@ -10,6 +10,6 @@ final class NoErrorToRetry extends RuntimeException { public function __construct() { - parent::__construct('No error to retry'); + parent::__construct('No error to retry.'); } } diff --git a/src/Projection/RetryStrategy/ClockBasedRetryStrategy.php b/src/Subscription/RetryStrategy/ClockBasedRetryStrategy.php similarity index 81% rename from src/Projection/RetryStrategy/ClockBasedRetryStrategy.php rename to src/Subscription/RetryStrategy/ClockBasedRetryStrategy.php index cd4ea1ac1..7c6787bea 100644 --- a/src/Projection/RetryStrategy/ClockBasedRetryStrategy.php +++ b/src/Subscription/RetryStrategy/ClockBasedRetryStrategy.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\RetryStrategy; +namespace Patchlevel\EventSourcing\Subscription\RetryStrategy; use DateTimeImmutable; use Patchlevel\EventSourcing\Clock\SystemClock; -use Patchlevel\EventSourcing\Projection\Projection\Projection; +use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Clock\ClockInterface; use function round; @@ -30,19 +30,19 @@ public function __construct( ) { } - public function shouldRetry(Projection $projection): bool + public function shouldRetry(Subscription $subscription): bool { - if ($projection->retryAttempt() >= $this->maxAttempts) { + if ($subscription->retryAttempt() >= $this->maxAttempts) { return false; } - $lastSavedAt = $projection->lastSavedAt(); + $lastSavedAt = $subscription->lastSavedAt(); if ($lastSavedAt === null) { return false; } - $nextRetryDate = $this->calculateNextRetryDate($lastSavedAt, $projection->retryAttempt()); + $nextRetryDate = $this->calculateNextRetryDate($lastSavedAt, $subscription->retryAttempt()); return $nextRetryDate <= $this->clock->now(); } @@ -52,7 +52,7 @@ private function calculateNextRetryDate(DateTimeImmutable $lastDate, int $attemp $nextDate = $lastDate->modify(sprintf('+%d seconds', $this->calculateDelay($attempt))); if ($nextDate === false) { - throw new UnexpectedError('Could not calculate next date'); + throw new UnexpectedError('Could not calculate next date.'); } return $nextDate; diff --git a/src/Subscription/RetryStrategy/NoRetryStrategy.php b/src/Subscription/RetryStrategy/NoRetryStrategy.php new file mode 100644 index 000000000..0dfd29799 --- /dev/null +++ b/src/Subscription/RetryStrategy/NoRetryStrategy.php @@ -0,0 +1,15 @@ +connection->createQueryBuilder() ->select('*') - ->from($this->projectionTable) + ->from($this->tableName) ->where('id = :id') ->getSQL(); /** @var Data|false $result */ - $result = $this->connection->fetchAssociative($sql, ['id' => $projectionId]); + $result = $this->connection->fetchAssociative($sql, ['id' => $subscriptionId]); if ($result === false) { - throw new ProjectionNotFound($projectionId); + throw new SubscriptionNotFound($subscriptionId); } - return $this->createProjection($result); + return $this->createSubscription($result); } - /** @return list */ - public function find(ProjectionCriteria|null $criteria = null): array + /** @return list */ + public function find(SubscriptionCriteria|null $criteria = null): array { $qb = $this->connection->createQueryBuilder() ->select('*') - ->from($this->projectionTable); + ->from($this->tableName); if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { $qb->forUpdate(); @@ -105,7 +103,7 @@ public function find(ProjectionCriteria|null $criteria = null): array $qb->andWhere('status IN (:status)') ->setParameter( 'status', - array_map(static fn (ProjectionStatus $status) => $status->value, $criteria->status), + array_map(static fn (Status $status) => $status->value, $criteria->status), ArrayParameterType::STRING, ); } @@ -115,30 +113,30 @@ public function find(ProjectionCriteria|null $criteria = null): array $result = $qb->fetchAllAssociative(); return array_map( - fn (array $data) => $this->createProjection($data), + fn (array $data) => $this->createSubscription($data), $result, ); } - public function add(Projection $projection): void + public function add(Subscription $subscription): void { - $projectionError = $projection->projectionError(); + $subscriptionError = $subscription->subscriptionError(); - $projection->updateLastSavedAt($this->clock->now()); + $subscription->updateLastSavedAt($this->clock->now()); $this->connection->insert( - $this->projectionTable, + $this->tableName, [ - 'id' => $projection->id(), - 'group_name' => $projection->group(), - 'run_mode' => $projection->runMode()->value, - 'status' => $projection->status()->value, - 'position' => $projection->position(), - 'error_message' => $projectionError?->errorMessage, - 'error_previous_status' => $projectionError?->previousStatus?->value, - 'error_context' => $projectionError?->errorContext !== null ? json_encode($projectionError->errorContext, JSON_THROW_ON_ERROR) : null, - 'retry_attempt' => $projection->retryAttempt(), - 'last_saved_at' => $projection->lastSavedAt(), + 'id' => $subscription->id(), + 'group_name' => $subscription->group(), + 'run_mode' => $subscription->runMode()->value, + 'status' => $subscription->status()->value, + 'position' => $subscription->position(), + 'error_message' => $subscriptionError?->errorMessage, + 'error_previous_status' => $subscriptionError?->previousStatus?->value, + 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, + 'retry_attempt' => $subscription->retryAttempt(), + 'last_saved_at' => $subscription->lastSavedAt(), ], [ 'last_saved_at' => Types::DATETIME_IMMUTABLE, @@ -146,27 +144,27 @@ public function add(Projection $projection): void ); } - public function update(Projection $projection): void + public function update(Subscription $subscription): void { - $projectionError = $projection->projectionError(); + $subscriptionError = $subscription->subscriptionError(); - $projection->updateLastSavedAt($this->clock->now()); + $subscription->updateLastSavedAt($this->clock->now()); $effectedRows = $this->connection->update( - $this->projectionTable, + $this->tableName, [ - 'group_name' => $projection->group(), - 'run_mode' => $projection->runMode()->value, - 'status' => $projection->status()->value, - 'position' => $projection->position(), - 'error_message' => $projectionError?->errorMessage, - 'error_previous_status' => $projectionError?->previousStatus?->value, - 'error_context' => $projectionError?->errorContext !== null ? json_encode($projectionError->errorContext, JSON_THROW_ON_ERROR) : null, - 'retry_attempt' => $projection->retryAttempt(), - 'last_saved_at' => $projection->lastSavedAt(), + 'group_name' => $subscription->group(), + 'run_mode' => $subscription->runMode()->value, + 'status' => $subscription->status()->value, + 'position' => $subscription->position(), + 'error_message' => $subscriptionError?->errorMessage, + 'error_previous_status' => $subscriptionError?->previousStatus?->value, + 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, + 'retry_attempt' => $subscription->retryAttempt(), + 'last_saved_at' => $subscription->lastSavedAt(), ], [ - 'id' => $projection->id(), + 'id' => $subscription->id(), ], [ 'last_saved_at' => Types::DATETIME_IMMUTABLE, @@ -174,13 +172,13 @@ public function update(Projection $projection): void ); if ($effectedRows === 0) { - throw new ProjectionNotFound($projection->id()); + throw new SubscriptionNotFound($subscription->id()); } } - public function remove(Projection $projection): void + public function remove(Subscription $subscription): void { - $this->connection->delete($this->projectionTable, ['id' => $projection->id()]); + $this->connection->delete($this->tableName, ['id' => $subscription->id()]); } public function inLock(Closure $closure): void @@ -200,7 +198,7 @@ public function inLock(Closure $closure): void public function configureSchema(Schema $schema, Connection $connection): void { - $table = $schema->createTable($this->projectionTable); + $table = $schema->createTable($this->tableName); $table->addColumn('id', Types::STRING) ->setLength(255) @@ -235,20 +233,20 @@ public function configureSchema(Schema $schema, Connection $connection): void } /** @param Data $row */ - private function createProjection(array $row): Projection + private function createSubscription(array $row): Subscription { $context = $row['error_context'] !== null ? json_decode($row['error_context'], true, 512, JSON_THROW_ON_ERROR) : null; - return new Projection( + return new Subscription( $row['id'], $row['group_name'], RunMode::from($row['run_mode']), - ProjectionStatus::from($row['status']), + Status::from($row['status']), $row['position'], - $row['error_message'] !== null ? new ProjectionError( + $row['error_message'] !== null ? new SubscriptionError( $row['error_message'], - $row['error_previous_status'] !== null ? ProjectionStatus::from($row['error_previous_status']) : ProjectionStatus::New, + $row['error_previous_status'] !== null ? Status::from($row['error_previous_status']) : Status::New, $context, ) : null, $row['retry_attempt'], diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php new file mode 100644 index 000000000..ebbf8800d --- /dev/null +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -0,0 +1,95 @@ + */ + private array $subscriptions = []; + + /** @param list $subscriptions */ + public function __construct(array $subscriptions = []) + { + foreach ($subscriptions as $subscription) { + $this->subscriptions[$subscription->id()] = $subscription; + } + } + + public function get(string $subscriptionId): Subscription + { + if (array_key_exists($subscriptionId, $this->subscriptions)) { + return $this->subscriptions[$subscriptionId]; + } + + throw new SubscriptionNotFound($subscriptionId); + } + + /** @return list */ + public function find(SubscriptionCriteria|null $criteria = null): array + { + $subscriptions = array_values($this->subscriptions); + + if ($criteria === null) { + return $subscriptions; + } + + return array_values( + array_filter( + $subscriptions, + static function (Subscription $subscription) use ($criteria): bool { + if ($criteria->ids !== null) { + if (!in_array($subscription->id(), $criteria->ids, true)) { + return false; + } + } + + if ($criteria->groups !== null) { + if (!in_array($subscription->group(), $criteria->groups, true)) { + return false; + } + } + + if ($criteria->status !== null) { + if (!in_array($subscription->status(), $criteria->status, true)) { + return false; + } + } + + return true; + }, + ), + ); + } + + public function add(Subscription $subscription): void + { + if (array_key_exists($subscription->id(), $this->subscriptions)) { + throw new SubscriptionAlreadyExists($subscription->id()); + } + + $this->subscriptions[$subscription->id()] = $subscription; + } + + public function update(Subscription $subscription): void + { + if (!array_key_exists($subscription->id(), $this->subscriptions)) { + throw new SubscriptionNotFound($subscription->id()); + } + + $this->subscriptions[$subscription->id()] = $subscription; + } + + public function remove(Subscription $subscription): void + { + unset($this->subscriptions[$subscription->id()]); + } +} diff --git a/src/Subscription/Store/LockableSubscriptionStore.php b/src/Subscription/Store/LockableSubscriptionStore.php new file mode 100644 index 000000000..48d62366e --- /dev/null +++ b/src/Subscription/Store/LockableSubscriptionStore.php @@ -0,0 +1,12 @@ +|null $ids - * @param list|null $groups - * @param list|null $status + * @param list|null $ids + * @param list|null $groups + * @param list|null $status */ public function __construct( public readonly array|null $ids = null, diff --git a/src/Subscription/Store/SubscriptionNotFound.php b/src/Subscription/Store/SubscriptionNotFound.php new file mode 100644 index 000000000..acfcea142 --- /dev/null +++ b/src/Subscription/Store/SubscriptionNotFound.php @@ -0,0 +1,17 @@ + */ + public function find(SubscriptionCriteria|null $criteria = null): array; + + /** @throws SubscriptionAlreadyExists */ + public function add(Subscription $subscription): void; + + /** @throws SubscriptionNotFound */ + public function update(Subscription $subscription): void; + + /** @throws SubscriptionNotFound */ + public function remove(Subscription $subscription): void; +} diff --git a/src/Projection/Projection/Store/TransactionCommitNotPossible.php b/src/Subscription/Store/TransactionCommitNotPossible.php similarity index 86% rename from src/Projection/Projection/Store/TransactionCommitNotPossible.php rename to src/Subscription/Store/TransactionCommitNotPossible.php index b9db0165a..6e137c3e6 100644 --- a/src/Projection/Projection/Store/TransactionCommitNotPossible.php +++ b/src/Subscription/Store/TransactionCommitNotPossible.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projection\Store; +namespace Patchlevel\EventSourcing\Subscription\Store; use RuntimeException; use Throwable; diff --git a/src/Projection/Projector/MetadataProjectorAccessor.php b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php similarity index 76% rename from src/Projection/Projector/MetadataProjectorAccessor.php rename to src/Subscription/Subscriber/MetadataSubscriberAccessor.php index 543c406fb..4c212021e 100644 --- a/src/Projection/Projector/MetadataProjectorAccessor.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php @@ -2,26 +2,26 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projector; +namespace Patchlevel\EventSourcing\Subscription\Subscriber; use Closure; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Metadata\Projector\ProjectorMetadata; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; +use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; +use Patchlevel\EventSourcing\Subscription\RunMode; use function array_key_exists; use function array_map; use function array_merge; -final class MetadataProjectorAccessor implements ProjectorAccessor +final class MetadataSubscriberAccessor implements SubscriberAccessor { /** @var array> */ private array $subscribeCache = []; public function __construct( - private readonly object $projector, - private readonly ProjectorMetadata $metadata, + private readonly object $subscriber, + private readonly SubscriberMetadata $metadata, ) { } @@ -48,7 +48,7 @@ public function setupMethod(): Closure|null return null; } - return $this->projector->$method(...); + return $this->subscriber->$method(...); } public function teardownMethod(): Closure|null @@ -59,7 +59,7 @@ public function teardownMethod(): Closure|null return null; } - return $this->projector->$method(...); + return $this->subscriber->$method(...); } /** @@ -80,7 +80,7 @@ public function subscribeMethods(string $eventClass): array $this->subscribeCache[$eventClass] = array_map( /** @return Closure(Message):void */ - fn (string $method) => $this->projector->$method(...), + fn (string $method) => $this->subscriber->$method(...), $methods, ); diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php b/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php new file mode 100644 index 000000000..324c1eada --- /dev/null +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php @@ -0,0 +1,51 @@ + */ + private array $subscribersMap = []; + + /** @param iterable $subscribers */ + public function __construct( + private readonly iterable $subscribers, + private readonly SubscriberMetadataFactory $metadataFactory = new AttributeSubscriberMetadataFactory(), + ) { + } + + /** @return iterable */ + public function all(): iterable + { + return array_values($this->subscriberAccessorMap()); + } + + public function get(string $id): SubscriberAccessor|null + { + $map = $this->subscriberAccessorMap(); + + return $map[$id] ?? null; + } + + /** @return array */ + private function subscriberAccessorMap(): array + { + if ($this->subscribersMap !== []) { + return $this->subscribersMap; + } + + foreach ($this->subscribers as $subscriber) { + $metadata = $this->metadataFactory->metadata($subscriber::class); + $this->subscribersMap[$metadata->id] = new MetadataSubscriberAccessor($subscriber, $metadata); + } + + return $this->subscribersMap; + } +} diff --git a/src/Projection/Projector/ProjectorAccessor.php b/src/Subscription/Subscriber/SubscriberAccessor.php similarity index 77% rename from src/Projection/Projector/ProjectorAccessor.php rename to src/Subscription/Subscriber/SubscriberAccessor.php index 00e378da5..46be4217a 100644 --- a/src/Projection/Projector/ProjectorAccessor.php +++ b/src/Subscription/Subscriber/SubscriberAccessor.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projector; +namespace Patchlevel\EventSourcing\Subscription\Subscriber; use Closure; use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; +use Patchlevel\EventSourcing\Subscription\RunMode; -interface ProjectorAccessor +interface SubscriberAccessor { public function id(): string; diff --git a/src/Subscription/Subscriber/SubscriberAccessorRepository.php b/src/Subscription/Subscriber/SubscriberAccessorRepository.php new file mode 100644 index 000000000..0e299a239 --- /dev/null +++ b/src/Subscription/Subscriber/SubscriberAccessorRepository.php @@ -0,0 +1,13 @@ + */ + public function all(): iterable; + + public function get(string $id): SubscriberAccessor|null; +} diff --git a/src/Subscription/Subscriber/SubscriberHelper.php b/src/Subscription/Subscriber/SubscriberHelper.php new file mode 100644 index 000000000..3243ac8fb --- /dev/null +++ b/src/Subscription/Subscriber/SubscriberHelper.php @@ -0,0 +1,27 @@ +metadata($subscriber)->id; + } + + private function metadata(object $subscriber): SubscriberMetadata + { + return $this->metadataFactory->metadata($subscriber::class); + } +} diff --git a/src/Subscription/Subscriber/SubscriberUtil.php b/src/Subscription/Subscriber/SubscriberUtil.php new file mode 100644 index 000000000..b73ad0a88 --- /dev/null +++ b/src/Subscription/Subscriber/SubscriberUtil.php @@ -0,0 +1,37 @@ +subscriberHelper()->subscriberId($this); + } +} diff --git a/src/Projection/Projector/TraceableProjectorAccessor.php b/src/Subscription/Subscriber/TraceableSubscriberAccessor.php similarity index 84% rename from src/Projection/Projector/TraceableProjectorAccessor.php rename to src/Subscription/Subscriber/TraceableSubscriberAccessor.php index 1ba70b22d..ded474b8b 100644 --- a/src/Projection/Projector/TraceableProjectorAccessor.php +++ b/src/Subscription/Subscriber/TraceableSubscriberAccessor.php @@ -2,21 +2,21 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projector; +namespace Patchlevel\EventSourcing\Subscription\Subscriber; use Closure; use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; use Patchlevel\EventSourcing\Repository\MessageDecorator\Trace; use Patchlevel\EventSourcing\Repository\MessageDecorator\TraceStack; +use Patchlevel\EventSourcing\Subscription\RunMode; use function array_map; /** @experimental */ -final class TraceableProjectorAccessor implements ProjectorAccessor +final class TraceableSubscriberAccessor implements SubscriberAccessor { public function __construct( - private readonly ProjectorAccessor $parent, + private readonly SubscriberAccessor $parent, private readonly TraceStack $traceStack, ) { } @@ -62,7 +62,7 @@ public function subscribeMethods(string $eventClass): array fn (Closure $closure) => function (Message $message) use ($closure): void { $trace = new Trace( $this->id(), - 'event_sourcing/projector/' . $this->group(), + 'event_sourcing/subscriber/' . $this->group(), ); $this->traceStack->add($trace); diff --git a/src/Subscription/Subscriber/TraceableSubscriberAccessorRepository.php b/src/Subscription/Subscriber/TraceableSubscriberAccessorRepository.php new file mode 100644 index 000000000..344073070 --- /dev/null +++ b/src/Subscription/Subscriber/TraceableSubscriberAccessorRepository.php @@ -0,0 +1,52 @@ + */ + private array $subscribersMap = []; + + public function __construct( + private readonly SubscriberAccessorRepository $parent, + private readonly TraceStack $traceStack, + ) { + } + + /** @return iterable */ + public function all(): iterable + { + return array_values($this->subscriberAccessorMap()); + } + + public function get(string $id): TraceableSubscriberAccessor|null + { + $map = $this->subscriberAccessorMap(); + + return $map[$id] ?? null; + } + + /** @return array */ + private function subscriberAccessorMap(): array + { + if ($this->subscribersMap !== []) { + return $this->subscribersMap; + } + + foreach ($this->parent->all() as $subscriberAccessor) { + $this->subscribersMap[$subscriberAccessor->id()] = new TraceableSubscriberAccessor( + $subscriberAccessor, + $this->traceStack, + ); + } + + return $this->subscribersMap; + } +} diff --git a/src/Projection/Projection/Projection.php b/src/Subscription/Subscription.php similarity index 67% rename from src/Projection/Projection/Projection.php rename to src/Subscription/Subscription.php index d51b06da8..5fc87c31f 100644 --- a/src/Projection/Projection/Projection.php +++ b/src/Subscription/Subscription.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projection; +namespace Patchlevel\EventSourcing\Subscription; use DateTimeImmutable; use Throwable; -final class Projection +final class Subscription { public const DEFAULT_GROUP = 'default'; @@ -15,9 +15,9 @@ public function __construct( private readonly string $id, private readonly string $group = self::DEFAULT_GROUP, private readonly RunMode $runMode = RunMode::FromBeginning, - private ProjectionStatus $status = ProjectionStatus::New, + private Status $status = Status::New, private int $position = 0, - private ProjectionError|null $error = null, + private SubscriptionError|null $error = null, private int $retryAttempt = 0, private DateTimeImmutable|null $lastSavedAt = null, ) { @@ -38,7 +38,7 @@ public function runMode(): RunMode return $this->runMode; } - public function status(): ProjectionStatus + public function status(): Status { return $this->status; } @@ -48,7 +48,7 @@ public function position(): int return $this->position; } - public function projectionError(): ProjectionError|null + public function subscriptionError(): SubscriptionError|null { return $this->error; } @@ -60,85 +60,85 @@ public function changePosition(int $position): void public function new(): void { - $this->status = ProjectionStatus::New; + $this->status = Status::New; $this->error = null; } public function isNew(): bool { - return $this->status === ProjectionStatus::New; + return $this->status === Status::New; } public function booting(): void { - $this->status = ProjectionStatus::Booting; + $this->status = Status::Booting; $this->error = null; } public function isBooting(): bool { - return $this->status === ProjectionStatus::Booting; + return $this->status === Status::Booting; } public function active(): void { - $this->status = ProjectionStatus::Active; + $this->status = Status::Active; $this->error = null; } public function isActive(): bool { - return $this->status === ProjectionStatus::Active; + return $this->status === Status::Active; } public function pause(): void { - $this->status = ProjectionStatus::Paused; + $this->status = Status::Paused; } public function isPaused(): bool { - return $this->status === ProjectionStatus::Paused; + return $this->status === Status::Paused; } public function finished(): void { - $this->status = ProjectionStatus::Finished; + $this->status = Status::Finished; $this->error = null; } public function isFinished(): bool { - return $this->status === ProjectionStatus::Finished; + return $this->status === Status::Finished; } public function outdated(): void { - $this->status = ProjectionStatus::Outdated; + $this->status = Status::Outdated; } public function isOutdated(): bool { - return $this->status === ProjectionStatus::Outdated; + return $this->status === Status::Outdated; } public function error(Throwable|string $error): void { $previousStatus = $this->status; - $this->status = ProjectionStatus::Error; + $this->status = Status::Error; if ($error instanceof Throwable) { - $this->error = ProjectionError::fromThrowable($previousStatus, $error); + $this->error = SubscriptionError::fromThrowable($previousStatus, $error); return; } - $this->error = new ProjectionError($error, $previousStatus); + $this->error = new SubscriptionError($error, $previousStatus); } public function isError(): bool { - return $this->status === ProjectionStatus::Error; + return $this->status === Status::Error; } public function retryAttempt(): int diff --git a/src/Projection/Projection/ProjectionError.php b/src/Subscription/SubscriptionError.php similarity index 59% rename from src/Projection/Projection/ProjectionError.php rename to src/Subscription/SubscriptionError.php index dc221bb37..013fdd979 100644 --- a/src/Projection/Projection/ProjectionError.php +++ b/src/Subscription/SubscriptionError.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projection; +namespace Patchlevel\EventSourcing\Subscription; use Throwable; @@ -10,18 +10,18 @@ * @psalm-type Trace = array{file?: string, line?: int, function?: string, class?: string, type?: string, args?: array} * @psalm-type Context = array{class: class-string, message: string, code: int|string, file: string, line: int, trace: list} */ -final class ProjectionError +final class SubscriptionError { /** @param list|null $errorContext */ public function __construct( public readonly string $errorMessage, - public readonly ProjectionStatus $previousStatus, + public readonly Status $previousStatus, public readonly array|null $errorContext = null, ) { } - public static function fromThrowable(ProjectionStatus $projectionStatus, Throwable $error): self + public static function fromThrowable(Status $status, Throwable $error): self { - return new self($error->getMessage(), $projectionStatus, ThrowableToErrorContextTransformer::transform($error)); + return new self($error->getMessage(), $status, ThrowableToErrorContextTransformer::transform($error)); } } diff --git a/src/Projection/Projection/ThrowableToErrorContextTransformer.php b/src/Subscription/ThrowableToErrorContextTransformer.php similarity index 91% rename from src/Projection/Projection/ThrowableToErrorContextTransformer.php rename to src/Subscription/ThrowableToErrorContextTransformer.php index c2b2fd84d..96f1571b8 100644 --- a/src/Projection/Projection/ThrowableToErrorContextTransformer.php +++ b/src/Subscription/ThrowableToErrorContextTransformer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Projection\Projection; +namespace Patchlevel\EventSourcing\Subscription; use Throwable; @@ -15,8 +15,8 @@ use function sprintf; /** - * @psalm-import-type Context from ProjectionError - * @psalm-import-type Trace from ProjectionError + * @psalm-import-type Context from SubscriptionError + * @psalm-import-type Trace from SubscriptionError */ final class ThrowableToErrorContextTransformer { diff --git a/tests/Benchmark/BasicImplementation/Processor/SendEmailProcessor.php b/tests/Benchmark/BasicImplementation/Processor/SendEmailProcessor.php index 0af5b1d99..7c583f203 100644 --- a/tests/Benchmark/BasicImplementation/Processor/SendEmailProcessor.php +++ b/tests/Benchmark/BasicImplementation/Processor/SendEmailProcessor.php @@ -4,13 +4,13 @@ namespace Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Processor; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\ProfileCreated; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\SendEmailMock; -#[Projector('send_email')] +#[Subscriber('send_email')] final class SendEmailProcessor { #[Subscribe(ProfileCreated::class)] diff --git a/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php b/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php index 64b3ada6f..fac643d87 100644 --- a/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php +++ b/tests/Benchmark/BasicImplementation/Projection/ProfileProjector.php @@ -6,21 +6,21 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Aggregate\AggregateHeader; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\ProfileCreated; use function assert; -#[Projector('profile')] +#[Subscriber('profile')] final class ProfileProjector { - use ProjectorUtil; + use SubscriberUtil; public function __construct( private Connection $connection, @@ -71,6 +71,6 @@ public function onNameChanged(Message $message): void public function table(): string { - return 'projection_' . $this->projectorId(); + return 'projection_' . $this->subscriberId(); } } diff --git a/tests/Benchmark/ProjectionistBench.php b/tests/Benchmark/SubscriptionEngineBench.php similarity index 78% rename from tests/Benchmark/ProjectionistBench.php rename to tests/Benchmark/SubscriptionEngineBench.php index fa8ff6045..ce6b09f57 100644 --- a/tests/Benchmark/ProjectionistBench.php +++ b/tests/Benchmark/SubscriptionEngineBench.php @@ -8,10 +8,6 @@ use Patchlevel\EventSourcing\EventBus\DefaultEventBus; use Patchlevel\EventSourcing\EventBus\EventBus; use Patchlevel\EventSourcing\EventBus\Serializer\DefaultHeadersSerializer; -use Patchlevel\EventSourcing\Projection\Projection\Store\DoctrineStore; -use Patchlevel\EventSourcing\Projection\Projectionist\DefaultProjectionist; -use Patchlevel\EventSourcing\Projection\Projectionist\Projectionist; -use Patchlevel\EventSourcing\Projection\Projector\MetadataProjectorAccessorRepository; use Patchlevel\EventSourcing\Repository\DefaultRepository; use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; @@ -19,6 +15,10 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Aggregate\Profile; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Processor\SendEmailProcessor; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\ProfileId; @@ -27,13 +27,13 @@ use PhpBench\Attributes as Bench; #[Bench\BeforeMethods('setUp')] -final class ProjectionistBench +final class SubscriptionEngineBench { private Store $store; private EventBus $bus; private Repository $repository; - private Projectionist $projectionist; + private SubscriptionEngine $subscriptionEngine; private AggregateRootId $id; @@ -57,7 +57,7 @@ public function setUp(): void $this->repository = new DefaultRepository($this->store, $this->bus, Profile::metadata()); - $projectionStore = new DoctrineStore( + $subscriptionStore = new DoctrineSubscriptionStore( $connection, ); @@ -65,7 +65,7 @@ public function setUp(): void $connection, new ChainDoctrineSchemaConfigurator([ $this->store, - $projectionStore, + $subscriptionStore, ]), ); @@ -81,10 +81,10 @@ public function setUp(): void $this->repository->save($profile); - $this->projectionist = new DefaultProjectionist( + $this->subscriptionEngine = new DefaultSubscriptionEngine( $this->store, - $projectionStore, - new MetadataProjectorAccessorRepository( + $subscriptionStore, + new MetadataSubscriberAccessorRepository( [ new ProfileProjector($connection), new SendEmailProcessor(), @@ -96,7 +96,7 @@ public function setUp(): void #[Bench\Revs(10)] public function benchHandle10000Events(): void { - $this->projectionist->boot(); - $this->projectionist->remove(); + $this->subscriptionEngine->boot(); + $this->subscriptionEngine->remove(); } } diff --git a/tests/Integration/BankAccountSplitStream/IntegrationTest.php b/tests/Integration/BankAccountSplitStream/IntegrationTest.php index 9df661241..9b9bd3c40 100644 --- a/tests/Integration/BankAccountSplitStream/IntegrationTest.php +++ b/tests/Integration/BankAccountSplitStream/IntegrationTest.php @@ -9,16 +9,16 @@ use Patchlevel\EventSourcing\EventBus\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; use Patchlevel\EventSourcing\Metadata\Event\AttributeEventMetadataFactory; -use Patchlevel\EventSourcing\Projection\Projection\Store\InMemoryStore; -use Patchlevel\EventSourcing\Projection\Projectionist\DefaultProjectionist; -use Patchlevel\EventSourcing\Projection\Projectionist\ProjectionistCriteria; -use Patchlevel\EventSourcing\Projection\Projector\MetadataProjectorAccessorRepository; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Repository\MessageDecorator\ChainMessageDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\SplitStreamDecorator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\BankAccountSplitStream\Aggregate\BankAccount; use Patchlevel\EventSourcing\Tests\Integration\BankAccountSplitStream\Events\BalanceAdded; @@ -58,10 +58,10 @@ public function testSuccessful(): void $bankAccountProjector = new BankAccountProjector($this->connection); - $projectionist = new DefaultProjectionist( + $engine = new DefaultSubscriptionEngine( $store, - new InMemoryStore(), - new MetadataProjectorAccessorRepository([$bankAccountProjector]), + new InMemorySubscriptionStore(), + new MetadataSubscriberAccessorRepository([$bankAccountProjector]), ); $eventBus = DefaultEventBus::create([$bankAccountProjector]); @@ -83,7 +83,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $projectionist->boot(new ProjectionistCriteria()); + $engine->boot(new SubscriptionEngineCriteria()); $bankAccountId = AccountId::fromString('1'); $bankAccount = BankAccount::create($bankAccountId, 'John'); diff --git a/tests/Integration/BankAccountSplitStream/Projection/BankAccountProjector.php b/tests/Integration/BankAccountSplitStream/Projection/BankAccountProjector.php index 310610db2..64f7da905 100644 --- a/tests/Integration/BankAccountSplitStream/Projection/BankAccountProjector.php +++ b/tests/Integration/BankAccountSplitStream/Projection/BankAccountProjector.php @@ -6,9 +6,9 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Table; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\Tests\Integration\BankAccountSplitStream\Events\BalanceAdded; @@ -16,7 +16,7 @@ use function assert; -#[Projector('dummy-1')] +#[Subscriber('dummy-1')] final class BankAccountProjector { public function __construct( diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index f8f3fe476..c1c0beaa5 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -8,16 +8,15 @@ use Patchlevel\EventSourcing\EventBus\DefaultEventBus; use Patchlevel\EventSourcing\EventBus\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; -use Patchlevel\EventSourcing\Projection\Projection\Store\InMemoryStore; -use Patchlevel\EventSourcing\Projection\Projectionist\DefaultProjectionist; -use Patchlevel\EventSourcing\Projection\Projectionist\ProjectionistCriteria; -use Patchlevel\EventSourcing\Projection\Projector\MetadataProjectorAccessorRepository; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Aggregate\Profile; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\MessageDecorator\FooMessageDecorator; @@ -55,10 +54,10 @@ public function testSuccessful(): void $profileProjector = new ProfileProjector($this->connection); - $projectionist = new DefaultProjectionist( + $engine = new DefaultSubscriptionEngine( $store, - new InMemoryStore(), - new MetadataProjectorAccessorRepository([$profileProjector]), + new InMemorySubscriptionStore(), + new MetadataSubscriberAccessorRepository([$profileProjector]), ); $eventBus = DefaultEventBus::create([ @@ -81,7 +80,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $projectionist->boot(new ProjectionistCriteria()); + $engine->boot(); $profileId = ProfileId::fromString('1'); $profile = Profile::create($profileId, 'John'); @@ -123,10 +122,10 @@ public function testSnapshot(): void $profileProjection = new ProfileProjector($this->connection); - $projectionist = new DefaultProjectionist( + $engine = new DefaultSubscriptionEngine( $store, - new InMemoryStore(), - new MetadataProjectorAccessorRepository([$profileProjection]), + new InMemorySubscriptionStore(), + new MetadataSubscriberAccessorRepository([$profileProjection]), ); $eventBus = DefaultEventBus::create([ @@ -149,7 +148,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $projectionist->boot(new ProjectionistCriteria()); + $engine->boot(); $profileId = ProfileId::fromString('1'); $profile = Profile::create($profileId, 'John'); diff --git a/tests/Integration/BasicImplementation/Projection/ProfileProjector.php b/tests/Integration/BasicImplementation/Projection/ProfileProjector.php index 7913679cf..5203d4b52 100644 --- a/tests/Integration/BasicImplementation/Projection/ProfileProjector.php +++ b/tests/Integration/BasicImplementation/Projection/ProfileProjector.php @@ -6,16 +6,16 @@ use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Table; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\Tests\Integration\BasicImplementation\Events\ProfileCreated; use function assert; -#[Projector('profile-1')] +#[Subscriber('profile-1')] final class ProfileProjector { public function __construct( diff --git a/tests/Integration/Store/Profile.php b/tests/Integration/Store/Profile.php index 38952065d..92fed2765 100644 --- a/tests/Integration/Store/Profile.php +++ b/tests/Integration/Store/Profile.php @@ -9,8 +9,8 @@ use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events\ProfileCreated; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\ProfileId; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\ProfileId; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot diff --git a/tests/Integration/Projectionist/Aggregate/Profile.php b/tests/Integration/Subscription/Aggregate/Profile.php similarity index 78% rename from tests/Integration/Projectionist/Aggregate/Profile.php rename to tests/Integration/Subscription/Aggregate/Profile.php index 64a4378a5..9cf4f9428 100644 --- a/tests/Integration/Projectionist/Aggregate/Profile.php +++ b/tests/Integration/Subscription/Aggregate/Profile.php @@ -2,16 +2,16 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist\Aggregate; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Aggregate; use Patchlevel\EventSourcing\Aggregate\BasicAggregateRoot; use Patchlevel\EventSourcing\Attribute\Aggregate; use Patchlevel\EventSourcing\Attribute\Apply; use Patchlevel\EventSourcing\Attribute\Id; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events\NameChanged; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events\ProfileCreated; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\ProfileId; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\ProfileId; #[Aggregate('profile')] final class Profile extends BasicAggregateRoot diff --git a/tests/Integration/Projectionist/Events/NameChanged.php b/tests/Integration/Subscription/Events/NameChanged.php similarity index 74% rename from tests/Integration/Projectionist/Events/NameChanged.php rename to tests/Integration/Subscription/Events/NameChanged.php index 3ffd6e5d1..ac9ebf8b0 100644 --- a/tests/Integration/Projectionist/Events/NameChanged.php +++ b/tests/Integration/Subscription/Events/NameChanged.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Events; use Patchlevel\EventSourcing\Attribute\Event; diff --git a/tests/Integration/Projectionist/Events/ProfileCreated.php b/tests/Integration/Subscription/Events/ProfileCreated.php similarity index 69% rename from tests/Integration/Projectionist/Events/ProfileCreated.php rename to tests/Integration/Subscription/Events/ProfileCreated.php index 73c2f9221..b4489452d 100644 --- a/tests/Integration/Projectionist/Events/ProfileCreated.php +++ b/tests/Integration/Subscription/Events/ProfileCreated.php @@ -2,11 +2,11 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Events; use Patchlevel\EventSourcing\Attribute\Event; use Patchlevel\EventSourcing\Serializer\Normalizer\IdNormalizer; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\ProfileId; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\ProfileId; #[Event('profile.created')] final class ProfileCreated diff --git a/tests/Integration/Projectionist/ProfileId.php b/tests/Integration/Subscription/ProfileId.php similarity index 85% rename from tests/Integration/Projectionist/ProfileId.php rename to tests/Integration/Subscription/ProfileId.php index 9d4992435..b6252bb68 100644 --- a/tests/Integration/Projectionist/ProfileId.php +++ b/tests/Integration/Subscription/ProfileId.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription; use Patchlevel\EventSourcing\Aggregate\AggregateRootId; diff --git a/tests/Integration/Projectionist/Projection/ErrorProducerProjector.php b/tests/Integration/Subscription/Subscriber/ErrorProducerSubscriber.php similarity index 81% rename from tests/Integration/Projectionist/Projection/ErrorProducerProjector.php rename to tests/Integration/Subscription/Subscriber/ErrorProducerSubscriber.php index 78fd766a0..2fab5b205 100644 --- a/tests/Integration/Projectionist/Projection/ErrorProducerProjector.php +++ b/tests/Integration/Subscription/Subscriber/ErrorProducerSubscriber.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist\Projection; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message; use RuntimeException; -#[Projector('error_producer')] -final class ErrorProducerProjector +#[Subscriber('error_producer')] +final class ErrorProducerSubscriber { public bool $setupError = false; public bool $subscribeError = false; diff --git a/tests/Integration/Projectionist/Projection/ProfileProcessor.php b/tests/Integration/Subscription/Subscriber/ProfileProcessor.php similarity index 71% rename from tests/Integration/Projectionist/Projection/ProfileProcessor.php rename to tests/Integration/Subscription/Subscriber/ProfileProcessor.php index 7982cdcff..c017389ab 100644 --- a/tests/Integration/Projectionist/Projection/ProfileProcessor.php +++ b/tests/Integration/Subscription/Subscriber/ProfileProcessor.php @@ -2,18 +2,18 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist\Projection; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\Repository\RepositoryManager; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Aggregate\Profile; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use function assert; -#[Projector('profile')] +#[Subscriber('profile')] final class ProfileProcessor { public function __construct( diff --git a/tests/Integration/Projectionist/Projection/ProfileProjector.php b/tests/Integration/Subscription/Subscriber/ProfileProjection.php similarity index 77% rename from tests/Integration/Projectionist/Projection/ProfileProjector.php rename to tests/Integration/Subscription/Subscriber/ProfileProjection.php index efd4969ed..a55032651 100644 --- a/tests/Integration/Projectionist/Projection/ProfileProjector.php +++ b/tests/Integration/Subscription/Subscriber/ProfileProjection.php @@ -2,24 +2,24 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist\Projection; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Schema\Table; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message; -use Patchlevel\EventSourcing\Projection\Projector\ProjectorUtil; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Events\ProfileCreated; +use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberUtil; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use function assert; -#[Projector('profile_1')] -final class ProfileProjector +#[Subscriber('profile_1')] +final class ProfileProjection { - use ProjectorUtil; + use SubscriberUtil; public function __construct( private Connection $connection, @@ -61,6 +61,6 @@ public function handleProfileCreated(Message $message): void private function tableName(): string { - return 'projection_' . $this->projectorId(); + return 'projection_' . $this->subscriberId(); } } diff --git a/tests/Integration/Projectionist/ProjectionistTest.php b/tests/Integration/Subscription/SubscriptionTest.php similarity index 50% rename from tests/Integration/Projectionist/ProjectionistTest.php rename to tests/Integration/Subscription/SubscriptionTest.php index 0b5ca0c50..b386a3f02 100644 --- a/tests/Integration/Projectionist/ProjectionistTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Integration\Projectionist; +namespace Patchlevel\EventSourcing\Tests\Integration\Subscription; use DateTimeImmutable; use Doctrine\DBAL\Connection; @@ -11,15 +11,6 @@ use Patchlevel\EventSourcing\EventBus\Message; use Patchlevel\EventSourcing\EventBus\Serializer\DefaultHeadersSerializer; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; -use Patchlevel\EventSourcing\Projection\Projection\Projection; -use Patchlevel\EventSourcing\Projection\Projection\ProjectionStatus; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; -use Patchlevel\EventSourcing\Projection\Projection\Store\DoctrineStore; -use Patchlevel\EventSourcing\Projection\Projectionist\DefaultProjectionist; -use Patchlevel\EventSourcing\Projection\Projectionist\ProjectionistCriteria; -use Patchlevel\EventSourcing\Projection\Projector\MetadataProjectorAccessorRepository; -use Patchlevel\EventSourcing\Projection\Projector\TraceableProjectorAccessorRepository; -use Patchlevel\EventSourcing\Projection\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Repository\DefaultRepositoryManager; use Patchlevel\EventSourcing\Repository\MessageDecorator\TraceDecorator; use Patchlevel\EventSourcing\Repository\MessageDecorator\TraceHeader; @@ -28,17 +19,26 @@ use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\DoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RunMode; +use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; +use Patchlevel\EventSourcing\Subscription\Subscriber\TraceableSubscriberAccessorRepository; +use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\DbalManager; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Aggregate\Profile; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Projection\ErrorProducerProjector; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Projection\ProfileProcessor; -use Patchlevel\EventSourcing\Tests\Integration\Projectionist\Projection\ProfileProjector; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Aggregate\Profile; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ErrorProducerSubscriber; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProcessor; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProjection; use PHPUnit\Framework\TestCase; use function iterator_to_array; /** @coversNothing */ -final class ProjectionistTest extends TestCase +final class SubscriptionTest extends TestCase { private Connection $connection; private Connection $projectionConnection; @@ -69,7 +69,7 @@ public function testHappyPath(): void $clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); - $projectionStore = new DoctrineStore( + $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, $clock, ); @@ -86,55 +86,55 @@ public function testHappyPath(): void $this->connection, new ChainDoctrineSchemaConfigurator([ $store, - $projectionStore, + $subscriptionStore, ]), ); $schemaDirector->create(); - $projectionist = new DefaultProjectionist( + $engine = new DefaultSubscriptionEngine( $store, - $projectionStore, - new MetadataProjectorAccessorRepository([new ProfileProjector($this->projectionConnection)]), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([new ProfileProjection($this->projectionConnection)]), ); self::assertEquals( - [new Projection('profile_1', lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'))], - $projectionist->projections(), + [new Subscription('profile_1', lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'))], + $engine->subscriptions(), ); - $projectionist->boot(); + $engine->boot(); self::assertEquals( [ - new Projection( + new Subscription( 'profile_1', - Projection::DEFAULT_GROUP, + Subscription::DEFAULT_GROUP, RunMode::FromBeginning, - ProjectionStatus::Active, + Status::Active, lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $projectionist->projections(), + $engine->subscriptions(), ); $profile = Profile::create(ProfileId::fromString('1'), 'John'); $repository->save($profile); - $projectionist->run(); + $engine->run(); self::assertEquals( [ - new Projection( + new Subscription( 'profile_1', - Projection::DEFAULT_GROUP, + Subscription::DEFAULT_GROUP, RunMode::FromBeginning, - ProjectionStatus::Active, + Status::Active, 1, lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $projectionist->projections(), + $engine->subscriptions(), ); $result = $this->projectionConnection->fetchAssociative( @@ -147,19 +147,19 @@ public function testHappyPath(): void self::assertSame('1', $result['id']); self::assertSame('John', $result['name']); - $projectionist->remove(); + $engine->remove(); self::assertEquals( [ - new Projection( + new Subscription( 'profile_1', - Projection::DEFAULT_GROUP, + Subscription::DEFAULT_GROUP, RunMode::FromBeginning, - ProjectionStatus::New, + Status::New, lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $projectionist->projections(), + $engine->subscriptions(), ); self::assertFalse( @@ -181,7 +181,7 @@ public function testErrorHandling(): void 'eventstore', ); - $projectionStore = new DoctrineStore( + $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, $clock, ); @@ -190,7 +190,7 @@ public function testErrorHandling(): void $this->connection, new ChainDoctrineSchemaConfigurator([ $store, - $projectionStore, + $subscriptionStore, ]), ); @@ -202,12 +202,12 @@ public function testErrorHandling(): void DefaultEventBus::create(), ); - $projector = new ErrorProducerProjector(); + $subscriber = new ErrorProducerSubscriber(); - $projectionist = new DefaultProjectionist( + $engine = new DefaultSubscriptionEngine( $store, - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), new ClockBasedRetryStrategy( $clock, ClockBasedRetryStrategy::DEFAULT_BASE_DELAY, @@ -216,89 +216,89 @@ public function testErrorHandling(): void ), ); - $projectionist->boot(); + $engine->boot(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Active, $projection->status()); - self::assertEquals(null, $projection->projectionError()); - self::assertEquals(0, $projection->retryAttempt()); + self::assertEquals(Status::Active, $subscription->status()); + self::assertEquals(null, $subscription->subscriptionError()); + self::assertEquals(0, $subscription->retryAttempt()); $repository = $manager->get(Profile::class); $profile = Profile::create(ProfileId::fromString('1'), 'John'); $repository->save($profile); - $projector->subscribeError = true; - $projectionist->run(); + $subscriber->subscribeError = true; + $engine->run(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Error, $projection->status()); - self::assertEquals('subscribe error', $projection->projectionError()?->errorMessage); - self::assertEquals(ProjectionStatus::Active, $projection->projectionError()?->previousStatus); - self::assertEquals(0, $projection->retryAttempt()); + self::assertEquals(Status::Error, $subscription->status()); + self::assertEquals('subscribe error', $subscription->subscriptionError()?->errorMessage); + self::assertEquals(Status::Active, $subscription->subscriptionError()?->previousStatus); + self::assertEquals(0, $subscription->retryAttempt()); - $projectionist->run(); + $engine->run(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Error, $projection->status()); - self::assertEquals('subscribe error', $projection->projectionError()?->errorMessage); - self::assertEquals(ProjectionStatus::Active, $projection->projectionError()?->previousStatus); - self::assertEquals(0, $projection->retryAttempt()); + self::assertEquals(Status::Error, $subscription->status()); + self::assertEquals('subscribe error', $subscription->subscriptionError()?->errorMessage); + self::assertEquals(Status::Active, $subscription->subscriptionError()?->previousStatus); + self::assertEquals(0, $subscription->retryAttempt()); $clock->sleep(5); - $projectionist->run(); + $engine->run(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Error, $projection->status()); - self::assertEquals('subscribe error', $projection->projectionError()?->errorMessage); - self::assertEquals(ProjectionStatus::Active, $projection->projectionError()?->previousStatus); - self::assertEquals(1, $projection->retryAttempt()); + self::assertEquals(Status::Error, $subscription->status()); + self::assertEquals('subscribe error', $subscription->subscriptionError()?->errorMessage); + self::assertEquals(Status::Active, $subscription->subscriptionError()?->previousStatus); + self::assertEquals(1, $subscription->retryAttempt()); $clock->sleep(10); - $projectionist->run(); + $engine->run(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Error, $projection->status()); - self::assertEquals('subscribe error', $projection->projectionError()?->errorMessage); - self::assertEquals(ProjectionStatus::Active, $projection->projectionError()?->previousStatus); - self::assertEquals(2, $projection->retryAttempt()); + self::assertEquals(Status::Error, $subscription->status()); + self::assertEquals('subscribe error', $subscription->subscriptionError()?->errorMessage); + self::assertEquals(Status::Active, $subscription->subscriptionError()?->previousStatus); + self::assertEquals(2, $subscription->retryAttempt()); - $projectionist->reactivate(new ProjectionistCriteria( + $engine->reactivate(new SubscriptionEngineCriteria( ids: ['error_producer'], )); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Active, $projection->status()); - self::assertEquals(null, $projection->projectionError()); - self::assertEquals(0, $projection->retryAttempt()); + self::assertEquals(Status::Active, $subscription->status()); + self::assertEquals(null, $subscription->subscriptionError()); + self::assertEquals(0, $subscription->retryAttempt()); - $projectionist->run(); + $engine->run(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Error, $projection->status()); - self::assertEquals('subscribe error', $projection->projectionError()?->errorMessage); - self::assertEquals(ProjectionStatus::Active, $projection->projectionError()?->previousStatus); - self::assertEquals(0, $projection->retryAttempt()); + self::assertEquals(Status::Error, $subscription->status()); + self::assertEquals('subscribe error', $subscription->subscriptionError()?->errorMessage); + self::assertEquals(Status::Active, $subscription->subscriptionError()?->previousStatus); + self::assertEquals(0, $subscription->retryAttempt()); $clock->sleep(5); - $projector->subscribeError = false; + $subscriber->subscribeError = false; - $projectionist->run(); + $engine->run(); - $projection = self::findProjection($projectionist->projections(), 'error_producer'); + $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); - self::assertEquals(ProjectionStatus::Active, $projection->status()); - self::assertEquals(null, $projection->projectionError()); - self::assertEquals(0, $projection->retryAttempt()); + self::assertEquals(Status::Active, $subscription->status()); + self::assertEquals(null, $subscription->subscriptionError()); + self::assertEquals(0, $subscription->retryAttempt()); } public function testProcessor(): void @@ -315,7 +315,7 @@ public function testProcessor(): void $clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); - $projectionStore = new DoctrineStore( + $subscriptionStore = new DoctrineSubscriptionStore( $this->connection, $clock, ); @@ -330,8 +330,8 @@ public function testProcessor(): void new TraceDecorator($traceStack), ); - $projectorAccessorRepository = new TraceableProjectorAccessorRepository( - new MetadataProjectorAccessorRepository([new ProfileProcessor($manager)]), + $subscriberAccessorRepository = new TraceableSubscriberAccessorRepository( + new MetadataSubscriberAccessorRepository([new ProfileProcessor($manager)]), $traceStack, ); @@ -341,53 +341,53 @@ public function testProcessor(): void $this->connection, new ChainDoctrineSchemaConfigurator([ $store, - $projectionStore, + $subscriptionStore, ]), ); $schemaDirector->create(); - $projectionist = new DefaultProjectionist( + $engine = new DefaultSubscriptionEngine( $store, - $projectionStore, - $projectorAccessorRepository, + $subscriptionStore, + $subscriberAccessorRepository, ); self::assertEquals( - [new Projection('profile', lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'))], - $projectionist->projections(), + [new Subscription('profile', lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'))], + $engine->subscriptions(), ); - $projectionist->boot(); + $engine->boot(); self::assertEquals( [ - new Projection( + new Subscription( 'profile', - Projection::DEFAULT_GROUP, + Subscription::DEFAULT_GROUP, RunMode::FromBeginning, - ProjectionStatus::Active, + Status::Active, lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $projectionist->projections(), + $engine->subscriptions(), ); $profile = Profile::create(ProfileId::fromString('1'), 'John'); $repository->save($profile); - $projectionist->run(); + $engine->run(); - $projections = $projectionist->projections(); + $subscriptions = $engine->subscriptions(); - self::assertCount(1, $projections); - self::assertArrayHasKey(0, $projections); + self::assertCount(1, $subscriptions); + self::assertArrayHasKey(0, $subscriptions); - $projection = $projections[0]; + $subscription = $subscriptions[0]; - self::assertEquals('profile', $projection->id()); + self::assertEquals('profile', $subscription->id()); - self::assertEquals(ProjectionStatus::Active, $projection->status()); + self::assertEquals(Status::Active, $subscription->status()); /** @var list $messages */ $messages = iterator_to_array($store->load()); @@ -399,22 +399,22 @@ public function testProcessor(): void new TraceHeader([ [ 'name' => 'profile', - 'category' => 'event_sourcing/projector/default', + 'category' => 'event_sourcing/subscriber/default', ], ]), $messages[1]->header(TraceHeader::class), ); } - /** @param list $projections */ - private static function findProjection(array $projections, string $id): Projection + /** @param list $subscriptions */ + private static function findSubscription(array $subscriptions, string $id): Subscription { - foreach ($projections as $projection) { - if ($projection->id() === $id) { - return $projection; + foreach ($subscriptions as $subscription) { + if ($subscription->id() === $id) { + return $subscription; } } - self::fail('projection not found'); + self::fail('subscription not found'); } } diff --git a/tests/Unit/Attribute/ProjectorTest.php b/tests/Unit/Attribute/SubscriberTest.php similarity index 53% rename from tests/Unit/Attribute/ProjectorTest.php rename to tests/Unit/Attribute/SubscriberTest.php index 8ad51c26e..fb14ed72a 100644 --- a/tests/Unit/Attribute/ProjectorTest.php +++ b/tests/Unit/Attribute/SubscriberTest.php @@ -4,15 +4,15 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Attribute; -use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Attribute\Subscriber; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\Attribute\Projector */ -final class ProjectorTest extends TestCase +/** @covers \Patchlevel\EventSourcing\Attribute\Subscriber */ +final class SubscriberTest extends TestCase { public function testInstantiate(): void { - $attribute = new Projector('foo'); + $attribute = new Subscriber('foo'); self::assertSame('foo', $attribute->id); } diff --git a/tests/Unit/Fixture/Dummy2Projector.php b/tests/Unit/Fixture/Dummy2Subscriber.php similarity index 88% rename from tests/Unit/Fixture/Dummy2Projector.php rename to tests/Unit/Fixture/Dummy2Subscriber.php index b64bd5337..032e96b36 100644 --- a/tests/Unit/Fixture/Dummy2Projector.php +++ b/tests/Unit/Fixture/Dummy2Subscriber.php @@ -4,14 +4,14 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Fixture; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message as EventMessage; -#[Projector('dummy2')] -final class Dummy2Projector +#[Subscriber('dummy2')] +final class Dummy2Subscriber { public EventMessage|null $handledMessage = null; public bool $createCalled = false; diff --git a/tests/Unit/Fixture/DummyProjector.php b/tests/Unit/Fixture/DummySubscriber.php similarity index 88% rename from tests/Unit/Fixture/DummyProjector.php rename to tests/Unit/Fixture/DummySubscriber.php index 59c1197b0..da5fa71c3 100644 --- a/tests/Unit/Fixture/DummyProjector.php +++ b/tests/Unit/Fixture/DummySubscriber.php @@ -4,14 +4,14 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Fixture; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\EventBus\Message as EventMessage; -#[Projector('dummy')] -final class DummyProjector +#[Subscriber('dummy')] +final class DummySubscriber { public EventMessage|null $handledMessage = null; public bool $createCalled = false; diff --git a/tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php similarity index 57% rename from tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php rename to tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index ab4064c13..379509630 100644 --- a/tests/Unit/Metadata/Projector/AttributeProjectorMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -2,42 +2,42 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Projector; +namespace Patchlevel\EventSourcing\Tests\Unit\Metadata\Subscriber; -use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; -use Patchlevel\EventSourcing\Metadata\Projector\AttributeProjectorMetadataFactory; -use Patchlevel\EventSourcing\Metadata\Projector\ClassIsNotAProjector; -use Patchlevel\EventSourcing\Metadata\Projector\DuplicateSetupMethod; -use Patchlevel\EventSourcing\Metadata\Projector\DuplicateTeardownMethod; +use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; +use Patchlevel\EventSourcing\Metadata\Subscriber\ClassIsNotASubscriber; +use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSetupMethod; +use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateTeardownMethod; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\Metadata\Projector\AttributeProjectorMetadataFactory */ -final class AttributeProjectorMetadataFactoryTest extends TestCase +/** @covers \Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory */ +final class AttributeSubscriberMetadataFactoryTest extends TestCase { - public function testNotAProjection(): void + public function testNotASubscriber(): void { - $this->expectException(ClassIsNotAProjector::class); + $this->expectException(ClassIsNotASubscriber::class); - $projection = new class { + $subscriber = new class { }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); } - public function testEmptyProjection(): void + public function testEmptySubscriber(): void { - $projection = new #[Projector('foo')] + $subscriber = new #[Subscriber('foo')] class { }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadata = $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); self::assertSame([], $metadata->subscribeMethods); self::assertNull($metadata->setupMethod); @@ -45,9 +45,9 @@ class { self::assertSame('foo', $metadata->id); } - public function testStandardProjection(): void + public function testStandardSubscriber(): void { - $projection = new #[Projector('foo')] + $subscriber = new #[Subscriber('foo')] class { #[Subscribe(ProfileVisited::class)] public function handle(): void @@ -65,8 +65,8 @@ public function drop(): void } }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadata = $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); self::assertEquals( [ProfileVisited::class => ['handle']], @@ -79,7 +79,7 @@ public function drop(): void public function testMultipleHandlerOnOneMethod(): void { - $projection = new #[Projector('foo')] + $subscriber = new #[Subscriber('foo')] class { #[Subscribe(ProfileVisited::class)] #[Subscribe(ProfileCreated::class)] @@ -88,8 +88,8 @@ public function handle(): void } }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadata = $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); self::assertEquals( [ @@ -102,7 +102,7 @@ public function handle(): void public function testSubscribeAll(): void { - $projection = new #[Projector('foo')] + $subscriber = new #[Subscriber('foo')] class { #[Subscribe(Subscribe::ALL)] public function handle(): void @@ -110,8 +110,8 @@ public function handle(): void } }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadata = $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); self::assertEquals( [ @@ -125,7 +125,7 @@ public function testDuplicateSetupAttributeException(): void { $this->expectException(DuplicateSetupMethod::class); - $projection = new #[Projector('foo')] + $subscriber = new #[Subscriber('foo')] class { #[Setup] public function create1(): void @@ -138,15 +138,15 @@ public function create2(): void } }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); } public function testDuplicateTeardownAttributeException(): void { $this->expectException(DuplicateTeardownMethod::class); - $projection = new #[Projector('foo')] + $subscriber = new #[Subscriber('foo')] class { #[Teardown] public function drop1(): void @@ -159,7 +159,7 @@ public function drop2(): void } }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - $metadataFactory->metadata($projection::class); + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); } } diff --git a/tests/Unit/Projection/DummyStore.php b/tests/Unit/Projection/DummyStore.php deleted file mode 100644 index 0bad434e8..000000000 --- a/tests/Unit/Projection/DummyStore.php +++ /dev/null @@ -1,59 +0,0 @@ - */ - public array $addedProjections = []; - - /** @var list */ - public array $updatedProjections = []; - - /** @var list */ - public array $removedProjections = []; - - /** @param list $projections */ - public function __construct(array $projections = []) - { - $this->parentStore = new InMemoryStore($projections); - } - - public function get(string $projectionId): Projection - { - return $this->parentStore->get($projectionId); - } - - /** @return list */ - public function find(ProjectionCriteria|null $criteria = null): array - { - return $this->parentStore->find($criteria); - } - - public function add(Projection $projection): void - { - $this->parentStore->add($projection); - $this->addedProjections[] = clone $projection; - } - - public function update(Projection $projection): void - { - $this->parentStore->update($projection); - $this->updatedProjections[] = clone $projection; - } - - public function remove(Projection $projection): void - { - $this->parentStore->remove($projection); - $this->removedProjections[] = clone $projection; - } -} diff --git a/tests/Unit/Projection/Projection/ProjectionAlreadyExistsTest.php b/tests/Unit/Projection/Projection/ProjectionAlreadyExistsTest.php deleted file mode 100644 index d50fcba01..000000000 --- a/tests/Unit/Projection/Projection/ProjectionAlreadyExistsTest.php +++ /dev/null @@ -1,24 +0,0 @@ -getMessage(), - ); - - self::assertSame(0, $exception->getCode()); - } -} diff --git a/tests/Unit/Projection/Projection/ProjectionCriteriaTest.php b/tests/Unit/Projection/Projection/ProjectionCriteriaTest.php deleted file mode 100644 index 4a7d2b7dc..000000000 --- a/tests/Unit/Projection/Projection/ProjectionCriteriaTest.php +++ /dev/null @@ -1,20 +0,0 @@ -ids); - } -} diff --git a/tests/Unit/Projection/Projection/ProjectionErrorTest.php b/tests/Unit/Projection/Projection/ProjectionErrorTest.php deleted file mode 100644 index 633e0b204..000000000 --- a/tests/Unit/Projection/Projection/ProjectionErrorTest.php +++ /dev/null @@ -1,26 +0,0 @@ -errorMessage); - self::assertSame(ProjectionStatus::Active, $error->previousStatus); - self::assertIsArray($error->errorContext); - } -} diff --git a/tests/Unit/Projection/Projection/ProjectionNotFoundTest.php b/tests/Unit/Projection/Projection/ProjectionNotFoundTest.php deleted file mode 100644 index c8e22e3e8..000000000 --- a/tests/Unit/Projection/Projection/ProjectionNotFoundTest.php +++ /dev/null @@ -1,23 +0,0 @@ -getMessage(), - ); - self::assertSame(0, $exception->getCode()); - } -} diff --git a/tests/Unit/Projection/Projection/ProjectionTest.php b/tests/Unit/Projection/Projection/ProjectionTest.php deleted file mode 100644 index 6c23af7b6..000000000 --- a/tests/Unit/Projection/Projection/ProjectionTest.php +++ /dev/null @@ -1,149 +0,0 @@ -id()); - self::assertEquals(ProjectionStatus::New, $projection->status()); - self::assertEquals(0, $projection->position()); - self::assertTrue($projection->isNew()); - self::assertFalse($projection->isBooting()); - self::assertFalse($projection->isActive()); - self::assertFalse($projection->isError()); - self::assertFalse($projection->isOutdated()); - } - - public function testBooting(): void - { - $projection = new Projection( - 'test', - ); - - $projection->booting(); - - self::assertEquals(ProjectionStatus::Booting, $projection->status()); - self::assertFalse($projection->isNew()); - self::assertTrue($projection->isBooting()); - self::assertFalse($projection->isActive()); - self::assertFalse($projection->isError()); - self::assertFalse($projection->isOutdated()); - } - - public function testActive(): void - { - $projection = new Projection( - 'test', - ); - - $projection->active(); - - self::assertEquals(ProjectionStatus::Active, $projection->status()); - self::assertFalse($projection->isNew()); - self::assertFalse($projection->isBooting()); - self::assertTrue($projection->isActive()); - self::assertFalse($projection->isError()); - self::assertFalse($projection->isOutdated()); - } - - public function testError(): void - { - $projection = new Projection( - 'test', - ); - - $exception = new RuntimeException('test'); - - $projection->error($exception); - - self::assertEquals(ProjectionStatus::Error, $projection->status()); - self::assertFalse($projection->isNew()); - self::assertFalse($projection->isBooting()); - self::assertFalse($projection->isActive()); - self::assertTrue($projection->isError()); - self::assertFalse($projection->isOutdated()); - self::assertEquals( - new ProjectionError( - 'test', - ProjectionStatus::New, - ThrowableToErrorContextTransformer::transform($exception), - ), - $projection->projectionError(), - ); - } - - public function testOutdated(): void - { - $projection = new Projection( - 'test', - ); - - $projection->outdated(); - - self::assertEquals(ProjectionStatus::Outdated, $projection->status()); - self::assertFalse($projection->isNew()); - self::assertFalse($projection->isBooting()); - self::assertFalse($projection->isActive()); - self::assertFalse($projection->isError()); - self::assertTrue($projection->isOutdated()); - } - - public function testChangePosition(): void - { - $projection = new Projection( - 'test', - ); - - $projection->changePosition(10); - - self::assertEquals(10, $projection->position()); - } - - public function testCanNotRetry(): void - { - $this->expectException(NoErrorToRetry::class); - - $projection = new Projection( - 'test', - ); - - $projection->doRetry(); - } - - public function testDoRetry(): void - { - $projection = new Projection( - 'test', - 'default', - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError('test', ProjectionStatus::New, []), - ); - - self::assertEquals(null, $projection->retryAttempt()); - $projection->doRetry(); - - self::assertEquals(1, $projection->retryAttempt()); - $projection->resetRetry(); - - self::assertEquals(null, $projection->retryAttempt()); - } -} diff --git a/tests/Unit/Projection/Projection/Store/InMemoryStoreTest.php b/tests/Unit/Projection/Projection/Store/InMemoryStoreTest.php deleted file mode 100644 index cbc7eec72..000000000 --- a/tests/Unit/Projection/Projection/Store/InMemoryStoreTest.php +++ /dev/null @@ -1,154 +0,0 @@ -add($projection); - - self::assertEquals($projection, $store->get($id)); - self::assertEquals([$projection], $store->find()); - } - - public function testAddDuplicated(): void - { - $this->expectException(ProjectionAlreadyExists::class); - - $id = 'test'; - $projection = new Projection($id); - - $store = new InMemoryStore([$projection]); - $store->add($projection); - } - - public function testUpdate(): void - { - $id = 'test'; - $projection = new Projection($id); - - $store = new InMemoryStore([$projection]); - - $store->update($projection); - - self::assertEquals($projection, $store->get($id)); - self::assertEquals([$projection], $store->find()); - } - - public function testUpdateNotFound(): void - { - $this->expectException(ProjectionNotFound::class); - - $id = 'test'; - $projection = new Projection($id); - - $store = new InMemoryStore(); - - $store->update($projection); - } - - public function testNotFound(): void - { - $this->expectException(ProjectionNotFound::class); - - $store = new InMemoryStore(); - $store->get('test'); - } - - public function testRemove(): void - { - $id = 'test'; - $projection = new Projection($id); - - $store = new InMemoryStore([$projection]); - - $store->remove($projection); - - self::assertEquals([], $store->find()); - } - - public function testFind(): void - { - $projection1 = new Projection('1'); - $projection2 = new Projection('2'); - - $store = new InMemoryStore([$projection1, $projection2]); - - self::assertEquals([$projection1, $projection2], $store->find()); - } - - public function testFindById(): void - { - $projection1 = new Projection('1'); - $projection2 = new Projection('2'); - - $store = new InMemoryStore([$projection1, $projection2]); - - $criteria = new ProjectionCriteria( - ids: ['1'], - ); - - self::assertEquals([$projection1], $store->find($criteria)); - } - - public function testFindByGroup(): void - { - $projection1 = new Projection('1', group: 'group1'); - $projection2 = new Projection('2', group: 'group2'); - - $store = new InMemoryStore([$projection1, $projection2]); - - $criteria = new ProjectionCriteria( - groups: ['group1'], - ); - - self::assertEquals([$projection1], $store->find($criteria)); - } - - public function testFindByStatus(): void - { - $projection1 = new Projection('1', status: ProjectionStatus::New); - $projection2 = new Projection('2', status: ProjectionStatus::Booting); - - $store = new InMemoryStore([$projection1, $projection2]); - - $criteria = new ProjectionCriteria( - status: [ProjectionStatus::New], - ); - - self::assertEquals([$projection1], $store->find($criteria)); - } - - public function testFindByAll(): void - { - $projection1 = new Projection('1', group: 'group1', status: ProjectionStatus::New); - $projection2 = new Projection('2', group: 'group2', status: ProjectionStatus::Booting); - - $store = new InMemoryStore([$projection1, $projection2]); - - $criteria = new ProjectionCriteria( - ids: ['1'], - groups: ['group1'], - status: [ProjectionStatus::New], - ); - - self::assertEquals([$projection1], $store->find($criteria)); - } -} diff --git a/tests/Unit/Projection/Projectionist/DefaultProjectionistTest.php b/tests/Unit/Projection/Projectionist/DefaultProjectionistTest.php deleted file mode 100644 index b87f83e0b..000000000 --- a/tests/Unit/Projection/Projectionist/DefaultProjectionistTest.php +++ /dev/null @@ -1,1873 +0,0 @@ -prophesize(Store::class); - $streamableStore->load($this->criteria())->shouldNotBeCalled(); - - $store = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $store, - new MetadataProjectorAccessorRepository([]), - ); - - $projectionist->boot(); - - self::assertEquals([], $store->addedProjections); - self::assertEquals([], $store->updatedProjections); - } - - public function testBootDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([]))->shouldBeCalledOnce(); - - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ], $projectionStore->updatedProjections); - } - - public function testBootWithoutCreateMethod(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection($projectionId), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 1, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - } - - public function testBootWithMethods(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public Message|null $message = null; - public bool $created = false; - - #[Setup] - public function create(): void - { - $this->created = true; - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore(); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 1, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertTrue($projector->created); - self::assertSame($message, $projector->message); - } - - public function testBootWithLimit(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public Message|null $message = null; - public bool $created = false; - - #[Setup] - public function create(): void - { - $this->created = true; - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore(); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(new ProjectionistCriteria(), 1); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertTrue($projector->created); - self::assertSame($message, $projector->message); - } - - public function testBootingWithSkip(): void - { - $projectionId1 = 'test1'; - $projector1 = new #[ProjectionAttribute('test1')] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionId2 = 'test2'; - $projector2 = new #[ProjectionAttribute('test2')] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId1, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - new Projection( - $projectionId2, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 1, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector1, $projector2]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId1, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 1, - ), - new Projection( - $projectionId2, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 1, - ), - new Projection( - $projectionId1, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - new Projection( - $projectionId2, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertSame($message, $projector1->message); - self::assertNull($projector2->message); - } - - public function testBootWithCreateError(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - }; - - $projectionStore = new DummyStore([ - new Projection($projectionId), - ]); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->shouldNotBeCalled(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals( - [ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError( - 'ERROR', - ProjectionStatus::New, - ThrowableToErrorContextTransformer::transform($projector->exception), - ), - ), - ], - $projectionStore->updatedProjections, - ); - } - - public function testBootingWithGabInIndex(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - /** @var list */ - public array $messages = []; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->messages[] = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([1 => $message1, 3 => $message2]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - 3, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 3, - ), - ], $projectionStore->updatedProjections); - - self::assertSame([$message1, $message2], $projector->messages); - } - - public function testBootingWithFromNow(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test', runMode: RunMode::FromNow)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromNow, - ProjectionStatus::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load(null, 1, null, true)->willReturn(new ArrayStream([$message1]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromNow, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertNull($projector->message); - } - - public function testBootingWithFromNowWithEmtpyStream(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test', runMode: RunMode::FromNow)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromNow, - ProjectionStatus::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load(null, 1, null, true)->willReturn(new ArrayStream([]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromNow, - ProjectionStatus::Active, - 0, - ), - ], $projectionStore->updatedProjections); - - self::assertNull($projector->message); - } - - public function testBootingWithOnlyOnce(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test', runMode: RunMode::Once)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::Once, - ProjectionStatus::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message1]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->boot(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::Once, - ProjectionStatus::Booting, - 1, - ), - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::Once, - ProjectionStatus::Finished, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertEquals($message1, $projector->message); - } - - public function testRunDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->run(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - } - - public function testRunning(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->run(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertSame($message, $projector->message); - } - - public function testRunningWithLimit(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore - ->load($this->criteria()) - ->willReturn(new ArrayStream([$message1, $message2])) - ->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->run(new ProjectionistCriteria(), 1); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertSame($message1, $projector->message); - } - - public function testRunningWithSkip(): void - { - $projectionId1 = 'test1'; - $projector1 = new #[ProjectionAttribute('test1')] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionId2 = 'test2'; - $projector2 = new #[ProjectionAttribute('test2')] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId1, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - new Projection( - $projectionId2, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector1, $projector2]), - ); - - $projectionist->run(); - - self::assertEquals([ - new Projection( - $projectionId1, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - new Projection( - $projectionId2, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 1, - ), - ], $projectionStore->updatedProjections); - - self::assertSame($message, $projector1->message); - self::assertNull($projector2->message); - } - - public function testRunningWithError(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->run(); - - self::assertEquals( - [ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError( - 'ERROR', - ProjectionStatus::Active, - ThrowableToErrorContextTransformer::transform($projector->exception), - ), - ), - ], - $projectionStore->updatedProjections, - ); - } - - public function testRunningMarkOutdated(): void - { - $projectionId = 'test'; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->shouldNotBeCalled(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([]), - ); - - $projectionist->run(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - 0, - ), - ], $projectionStore->updatedProjections); - } - - public function testRunningWithoutActiveProjectors(): void - { - $projectionId = 'test'; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->shouldNotBeCalled(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([]), - ); - - $projectionist->run(); - - self::assertEquals([], $projectionStore->updatedProjections); - } - - public function testRunningWithGabInIndex(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - /** @var list */ - public array $messages = []; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->messages[] = $message; - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([1 => $message1, 3 => $message2]))->shouldBeCalledOnce(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->run(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 3, - ), - ], $projectionStore->updatedProjections); - - self::assertSame([$message1, $message2], $projector->messages); - } - - public function testTeardownDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->teardown(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - } - - public function testTeardownWithoutTeardownMethod(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ); - - $projectionStore = new DummyStore([$projection]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->teardown(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([$projection], $projectionStore->removedProjections); - } - - public function testTeardownWithProjector(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public Message|null $message = null; - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ); - - $projectionStore = new DummyStore([$projection]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->teardown(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([$projection], $projectionStore->removedProjections); - self::assertTrue($projector->dropped); - } - - public function testTeardownWithProjectorAndError(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public Message|null $message = null; - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - throw new RuntimeException('ERROR'); - } - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->teardown(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([], $projectionStore->removedProjections); - } - - public function testTeardownWithoutProjector(): void - { - $projectorId = 'test'; - - $projectionStore = new DummyStore([ - new Projection( - $projectorId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([]), - ); - - $projectionist->teardown(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([], $projectionStore->removedProjections); - } - - public function testRemoveDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->remove(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - } - - public function testRemoveWithProjector(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ); - $projectionStore = new DummyStore([$projection]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->remove(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([$projection], $projectionStore->removedProjections); - self::assertTrue($projector->dropped); - } - - public function testRemoveWithoutDropMethod(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ); - $projectionStore = new DummyStore([$projection]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->remove(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([$projection], $projectionStore->removedProjections); - } - - public function testRemoveWithProjectorAndError(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - throw new RuntimeException('ERROR'); - } - }; - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ); - $projectionStore = new DummyStore([$projection]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->remove(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([$projection], $projectionStore->removedProjections); - } - - public function testRemoveWithoutProjector(): void - { - $projectorId = 'test'; - - $projection = new Projection( - $projectorId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ); - $projectionStore = new DummyStore([$projection]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([]), - ); - - $projectionist->remove(); - - self::assertEquals([], $projectionStore->updatedProjections); - self::assertEquals([$projection], $projectionStore->removedProjections); - } - - public function testReactiveDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->reactivate(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - } - - public function testReactivateError(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError('ERROR', ProjectionStatus::New), - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->reactivate(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - 0, - ), - ], $projectionStore->updatedProjections); - } - - public function testReactivateOutdated(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Outdated, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->reactivate(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ], $projectionStore->updatedProjections); - } - - public function testReactivatePaused(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Paused, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->reactivate(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ], $projectionStore->updatedProjections); - } - - public function testReactivateFinished(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Finished, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->reactivate(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ], $projectionStore->updatedProjections); - } - - public function testPauseDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->pause(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - } - - public function testPauseBooting(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Booting, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->pause(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Paused, - ), - ], $projectionStore->updatedProjections); - } - - public function testPauseActive(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->pause(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Paused, - ), - ], $projectionStore->updatedProjections); - } - - public function testPauseError(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $projectionStore = new DummyStore([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError('ERROR', ProjectionStatus::New), - ), - ]); - - $streamableStore = $this->prophesize(Store::class); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->pause(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Paused, - 0, - new ProjectionError('ERROR', ProjectionStatus::New), - ), - ], $projectionStore->updatedProjections); - } - - public function testGetProjectionAndDiscoverNewProjectors(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - $projectionStore = new DummyStore(); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projections = $projectionist->projections(); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projectionStore->addedProjections); - - self::assertEquals([ - new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::New, - ), - ], $projections); - } - - public function testRetry(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - #[Subscribe(ProfileVisited::class)] - public function subscribe(): void - { - throw new RuntimeException('ERROR2'); - } - }; - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError('ERROR', ProjectionStatus::Active), - ); - - $projectionStore = new DummyStore([$projection]); - - $retryStrategy = $this->prophesize(RetryStrategy::class); - $retryStrategy->shouldRetry($projection)->willReturn(true); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - $retryStrategy->reveal(), - ); - - $projectionist->run(); - - self::assertCount(2, $projectionStore->updatedProjections); - - [$update1, $update2] = $projectionStore->updatedProjections; - - self::assertEquals($update1, new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Active, - 0, - null, - 1, - )); - - self::assertEquals(ProjectionStatus::Error, $update2->status()); - self::assertEquals(ProjectionStatus::Active, $update2->projectionError()?->previousStatus); - self::assertEquals('ERROR2', $update2->projectionError()?->errorMessage); - self::assertEquals(1, $update2->retryAttempt()); - } - - public function testShouldNotRetry(): void - { - $projectionId = 'test'; - $projector = new #[ProjectionAttribute('test')] - class { - }; - - $streamableStore = $this->prophesize(Store::class); - - $projection = new Projection( - $projectionId, - Projection::DEFAULT_GROUP, - RunMode::FromBeginning, - ProjectionStatus::Error, - 0, - new ProjectionError('ERROR', ProjectionStatus::Active), - ); - - $projectionStore = new DummyStore([$projection]); - - $retryStrategy = $this->prophesize(RetryStrategy::class); - $retryStrategy->shouldRetry($projection)->willReturn(false); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore, - new MetadataProjectorAccessorRepository([$projector]), - $retryStrategy->reveal(), - ); - - $projectionist->run(); - - self::assertEquals([], $projectionStore->updatedProjections); - } - - #[DataProvider('methodProvider')] - public function testCriteria(string $method): void - { - $projector = new #[ProjectionAttribute('id1')] - class { - }; - - $projectionStore = $this->prophesize(ProjectionStore::class); - $projectionStore->find( - Argument::that( - static fn (ProjectionCriteria $criteria) => $criteria->ids === ['id1'] && $criteria->groups === ['group1'] - ), - )->willReturn([])->shouldBeCalled(); - - $projectionStore->find( - new ProjectionCriteria(), - )->willReturn([ - new Projection('id1'), - ])->shouldBeCalled(); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([])); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore->reveal(), - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionistCriteria = new ProjectionistCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $projectionist->{$method}($projectionistCriteria); - } - - #[DataProvider('methodProvider')] - public function testWithLockableStore(string $method): void - { - $projector = new #[ProjectionAttribute('id1')] - class { - }; - - $projectionStore = $this->prophesize(LockableProjectionStore::class); - $projectionStore->inLock(Argument::type(Closure::class))->will( - /** @param array{Closure} $args */ - static fn (array $args): mixed => $args[0]() - )->shouldBeCalled(); - $projectionStore->find(Argument::any())->willReturn([])->shouldBeCalled(); - - $projectionStore->find( - new ProjectionCriteria(), - )->willReturn([ - new Projection('id1'), - ])->shouldBeCalled(); - - $projectionStore->remove(Argument::type(Projection::class)); - $projectionStore->add(Argument::type(Projection::class)); - - $streamableStore = $this->prophesize(Store::class); - $streamableStore->load($this->criteria())->willReturn(new ArrayStream([])); - - $projectionist = new DefaultProjectionist( - $streamableStore->reveal(), - $projectionStore->reveal(), - new MetadataProjectorAccessorRepository([$projector]), - ); - - $projectionist->{$method}(); - } - - public static function methodProvider(): Generator - { - yield 'boot' => ['boot']; - yield 'run' => ['run']; - yield 'teardown' => ['teardown']; - yield 'remove' => ['remove']; - yield 'reactivate' => ['reactivate']; - yield 'projections' => ['projections']; - } - - private function criteria(int $fromIndex = 0): Criteria - { - return new Criteria(fromIndex: $fromIndex); - } -} diff --git a/tests/Unit/Projection/Projector/MetadataProjectorAccessorRepositoryTest.php b/tests/Unit/Projection/Projector/MetadataProjectorAccessorRepositoryTest.php deleted file mode 100644 index 48615474d..000000000 --- a/tests/Unit/Projection/Projector/MetadataProjectorAccessorRepositoryTest.php +++ /dev/null @@ -1,45 +0,0 @@ -all()); - self::assertNull($repository->get('foo')); - } - - public function testWithProjector(): void - { - $projector = new #[Projector('foo')] - class { - }; - $metadataFactory = new AttributeProjectorMetadataFactory(); - - $repository = new MetadataProjectorAccessorRepository( - [$projector], - $metadataFactory, - ); - - $accessor = new MetadataProjectorAccessor( - $projector, - $metadataFactory->metadata($projector::class), - ); - - self::assertEquals([$accessor], $repository->all()); - self::assertEquals($accessor, $repository->get('foo')); - } -} diff --git a/tests/Unit/Projection/Projector/MetadataProjectorAccessorTest.php b/tests/Unit/Projection/Projector/MetadataProjectorAccessorTest.php deleted file mode 100644 index 9b5d801e4..000000000 --- a/tests/Unit/Projection/Projector/MetadataProjectorAccessorTest.php +++ /dev/null @@ -1,206 +0,0 @@ -metadata($projector::class), - ); - - self::assertEquals('profile', $accessor->id()); - } - - public function testGroup(): void - { - $projector = new #[Projector('profile')] - class { - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - self::assertEquals('default', $accessor->group()); - } - - public function testRunMode(): void - { - $projector = new #[Projector('profile')] - class { - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - self::assertEquals(RunMode::FromBeginning, $accessor->runMode()); - } - - public function testSubscribeMethod(): void - { - $projector = new #[Projector('profile')] - class { - #[Subscribe(ProfileCreated::class)] - public function onProfileCreated(Message $message): void - { - } - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->subscribeMethods(ProfileCreated::class); - - self::assertEquals([ - $projector->onProfileCreated(...), - ], $result); - } - - public function testMultipleSubscribeMethod(): void - { - $projector = new #[Projector('profile')] - class { - #[Subscribe(ProfileCreated::class)] - public function onProfileCreated(Message $message): void - { - } - - #[Subscribe(ProfileCreated::class)] - public function onFoo(Message $message): void - { - } - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->subscribeMethods(ProfileCreated::class); - - self::assertEquals([ - $projector->onProfileCreated(...), - $projector->onFoo(...), - ], $result); - } - - public function testSubscribeAllMethod(): void - { - $projector = new #[Projector('profile')] - class { - #[Subscribe('*')] - public function onProfileCreated(Message $message): void - { - } - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->subscribeMethods(ProfileCreated::class); - - self::assertEquals([ - $projector->onProfileCreated(...), - ], $result); - } - - public function testSetupMethod(): void - { - $projector = new #[Projector('profile')] - class { - #[Setup] - public function method(): void - { - } - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->setupMethod(); - - self::assertEquals($projector->method(...), $result); - } - - public function testNotSetupMethod(): void - { - $projector = new #[Projector('profile')] - class { - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->setupMethod(); - - self::assertNull($result); - } - - public function testTeardownMethod(): void - { - $projector = new #[Projector('profile')] - class { - #[Teardown] - public function method(): void - { - } - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->teardownMethod(); - - self::assertEquals($projector->method(...), $result); - } - - public function testNotTeardownMethod(): void - { - $projector = new #[Projector('profile')] - class { - }; - - $accessor = new MetadataProjectorAccessor( - $projector, - (new AttributeProjectorMetadataFactory())->metadata($projector::class), - ); - - $result = $accessor->teardownMethod(); - - self::assertNull($result); - } -} diff --git a/tests/Unit/Projection/Projector/ProjectorHelperTest.php b/tests/Unit/Projection/Projector/ProjectorHelperTest.php deleted file mode 100644 index 738171c24..000000000 --- a/tests/Unit/Projection/Projector/ProjectorHelperTest.php +++ /dev/null @@ -1,24 +0,0 @@ -projectorId($projector)); - } -} diff --git a/tests/Unit/Projection/RetryStrategy/NoRetryStrategyTest.php b/tests/Unit/Projection/RetryStrategy/NoRetryStrategyTest.php deleted file mode 100644 index dc76fabe8..000000000 --- a/tests/Unit/Projection/RetryStrategy/NoRetryStrategyTest.php +++ /dev/null @@ -1,22 +0,0 @@ -shouldRetry( - new Projection('test'), - )); - } -} diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php new file mode 100644 index 000000000..caf41c80e --- /dev/null +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -0,0 +1,59 @@ + */ + public array $addedSubscriptions = []; + + /** @var list */ + public array $updatedSubscriptions = []; + + /** @var list */ + public array $removedSubscriptions = []; + + /** @param list $subscriptions */ + public function __construct(array $subscriptions = []) + { + $this->parentStore = new InMemorySubscriptionStore($subscriptions); + } + + public function get(string $subscriptionId): Subscription + { + return $this->parentStore->get($subscriptionId); + } + + /** @return list */ + public function find(SubscriptionCriteria|null $criteria = null): array + { + return $this->parentStore->find($criteria); + } + + public function add(Subscription $subscription): void + { + $this->parentStore->add($subscription); + $this->addedSubscriptions[] = clone $subscription; + } + + public function update(Subscription $subscription): void + { + $this->parentStore->update($subscription); + $this->updatedSubscriptions[] = clone $subscription; + } + + public function remove(Subscription $subscription): void + { + $this->parentStore->remove($subscription); + $this->removedSubscriptions[] = clone $subscription; + } +} diff --git a/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php new file mode 100644 index 000000000..67e85b3b1 --- /dev/null +++ b/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php @@ -0,0 +1,1873 @@ +prophesize(Store::class); + $streamableStore->load($this->criteria())->shouldNotBeCalled(); + + $store = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $store, + new MetadataSubscriberAccessorRepository([]), + ); + + $engine->boot(); + + self::assertEquals([], $store->addedSubscriptions); + self::assertEquals([], $store->updatedSubscriptions); + } + + public function testBootDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([]))->shouldBeCalledOnce(); + + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testBootWithoutCreateMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + 1, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testBootWithMethods(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + public Message|null $message = null; + public bool $created = false; + + #[Setup] + public function create(): void + { + $this->created = true; + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionStore = new DummySubscriptionStore(); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + 1, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertTrue($subscriber->created); + self::assertSame($message, $subscriber->message); + } + + public function testBootWithLimit(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + public Message|null $message = null; + public bool $created = false; + + #[Setup] + public function create(): void + { + $this->created = true; + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionStore = new DummySubscriptionStore(); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(new SubscriptionEngineCriteria(), 1); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertTrue($subscriber->created); + self::assertSame($message, $subscriber->message); + } + + public function testBootingWithSkip(): void + { + $subscriptionId1 = 'test1'; + $subscriber1 = new #[Subscriber('test1')] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionId2 = 'test2'; + $subscriber2 = new #[Subscriber('test2')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId1, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + 1, + ), + new Subscription( + $subscriptionId2, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + 1, + ), + new Subscription( + $subscriptionId1, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + new Subscription( + $subscriptionId2, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertSame($message, $subscriber1->message); + self::assertNull($subscriber2->message); + } + + public function testBootWithCreateError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->shouldNotBeCalled(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals( + [ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ], + $subscriptionStore->updatedSubscriptions, + ); + } + + public function testBootingWithGabInIndex(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([1 => $message1, 3 => $message2]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + 3, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 3, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertSame([$message1, $message2], $subscriber->messages); + } + + public function testBootingWithFromNow(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', runMode: RunMode::FromNow)] + 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::FromNow, + Status::Booting, + ), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load(null, 1, null, true)->willReturn(new ArrayStream([$message1]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromNow, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertNull($subscriber->message); + } + + public function testBootingWithFromNowWithEmtpyStream(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', runMode: RunMode::FromNow)] + 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::FromNow, + Status::Booting, + ), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load(null, 1, null, true)->willReturn(new ArrayStream([]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromNow, + Status::Active, + 0, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertNull($subscriber->message); + } + + public function testBootingWithOnlyOnce(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', runMode: 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message1]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->boot(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::Once, + Status::Booting, + 1, + ), + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::Once, + Status::Finished, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertEquals($message1, $subscriber->message); + } + + public function testRunDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->run(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + } + + public function testRunning(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->run(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertSame($message, $subscriber->message); + } + + public function testRunningWithLimit(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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->prophesize(Store::class); + $streamableStore + ->load($this->criteria()) + ->willReturn(new ArrayStream([$message1, $message2])) + ->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->run(new SubscriptionEngineCriteria(), 1); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertSame($message1, $subscriber->message); + } + + public function testRunningWithSkip(): void + { + $subscriptionId1 = 'test1'; + $subscriber1 = new #[Subscriber('test1')] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionId2 = 'test2'; + $subscriber2 = new #[Subscriber('test2')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), + ); + + $engine->run(); + + self::assertEquals([ + new Subscription( + $subscriptionId1, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + new Subscription( + $subscriptionId2, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertSame($message, $subscriber1->message); + self::assertNull($subscriber2->message); + } + + public function testRunningWithError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->run(); + + self::assertEquals( + [ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ], + $subscriptionStore->updatedSubscriptions, + ); + } + + public function testRunningMarkOutdated(): void + { + $subscriptionId = 'test'; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->shouldNotBeCalled(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([]), + ); + + $engine->run(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + 0, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testRunningWithoutActiveSubscribers(): void + { + $subscriptionId = 'test'; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->shouldNotBeCalled(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([]), + ); + + $engine->run(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + } + + public function testRunningWithGabInIndex(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([1 => $message1, 3 => $message2]))->shouldBeCalledOnce(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->run(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 3, + ), + ], $subscriptionStore->updatedSubscriptions); + + self::assertSame([$message1, $message2], $subscriber->messages); + } + + public function testTeardownDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->teardown(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + } + + public function testTeardownWithoutTeardownMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ); + + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->teardown(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([$subscription], $subscriptionStore->removedSubscriptions); + } + + public function testTeardownWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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::Outdated, + ); + + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->teardown(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([$subscription], $subscriptionStore->removedSubscriptions); + self::assertTrue($subscriber->dropped); + } + + public function testTeardownWithSubscriberAndError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + 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::Outdated, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->teardown(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([], $subscriptionStore->removedSubscriptions); + } + + public function testTeardownWithoutSubscriber(): void + { + $subscriberId = 'test'; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriberId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([]), + ); + + $engine->teardown(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([], $subscriptionStore->removedSubscriptions); + } + + public function testRemoveDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->remove(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + } + + public function testRemoveWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + $this->dropped = true; + } + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ); + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->remove(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([$subscription], $subscriptionStore->removedSubscriptions); + self::assertTrue($subscriber->dropped); + } + + public function testRemoveWithoutDropMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ); + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->remove(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([$subscription], $subscriptionStore->removedSubscriptions); + } + + public function testRemoveWithSubscriberAndError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + throw new RuntimeException('ERROR'); + } + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ); + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->remove(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([$subscription], $subscriptionStore->removedSubscriptions); + } + + public function testRemoveWithoutSubscriber(): void + { + $subscriberId = 'test'; + + $subscription = new Subscription( + $subscriberId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ); + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([]), + ); + + $engine->remove(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + self::assertEquals([$subscription], $subscriptionStore->removedSubscriptions); + } + + public function testReactiveDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->reactivate(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + } + + public function testReactivateError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->reactivate(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + 0, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testReactivateOutdated(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Outdated, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->reactivate(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testReactivatePaused(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Paused, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->reactivate(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testReactivateFinished(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Finished, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->reactivate(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testPauseDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->pause(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + } + + public function testPauseBooting(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->pause(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Paused, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testPauseActive(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->pause(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Paused, + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testPauseError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $subscriptionStore = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ]); + + $streamableStore = $this->prophesize(Store::class); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->pause(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Paused, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ], $subscriptionStore->updatedSubscriptions); + } + + public function testGetSubscriptionAndDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + $subscriptionStore = new DummySubscriptionStore(); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $subscriptions = $engine->subscriptions(); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptionStore->addedSubscriptions); + + self::assertEquals([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ], $subscriptions); + } + + public function testRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + #[Subscribe(ProfileVisited::class)] + public function subscribe(): void + { + throw new RuntimeException('ERROR2'); + } + }; + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([$message]))->shouldBeCalledOnce(); + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active), + ); + + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->prophesize(RetryStrategy::class); + $retryStrategy->shouldRetry($subscription)->willReturn(true); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + $retryStrategy->reveal(), + ); + + $engine->run(); + + self::assertCount(2, $subscriptionStore->updatedSubscriptions); + + [$update1, $update2] = $subscriptionStore->updatedSubscriptions; + + self::assertEquals($update1, new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 0, + null, + 1, + )); + + self::assertEquals(Status::Error, $update2->status()); + self::assertEquals(Status::Active, $update2->subscriptionError()?->previousStatus); + self::assertEquals('ERROR2', $update2->subscriptionError()?->errorMessage); + self::assertEquals(1, $update2->retryAttempt()); + } + + public function testShouldNotRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test')] + class { + }; + + $streamableStore = $this->prophesize(Store::class); + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active), + ); + + $subscriptionStore = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->prophesize(RetryStrategy::class); + $retryStrategy->shouldRetry($subscription)->willReturn(false); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore, + new MetadataSubscriberAccessorRepository([$subscriber]), + $retryStrategy->reveal(), + ); + + $engine->run(); + + self::assertEquals([], $subscriptionStore->updatedSubscriptions); + } + + #[DataProvider('methodProvider')] + public function testCriteria(string $method): void + { + $subscriber = new #[Subscriber('id1')] + class { + }; + + $subscriptionStore = $this->prophesize(SubscriptionStore::class); + $subscriptionStore->find( + Argument::that( + static fn (SubscriptionCriteria $criteria) => $criteria->ids === ['id1'] && $criteria->groups === ['group1'] + ), + )->willReturn([])->shouldBeCalled(); + + $subscriptionStore->find( + new SubscriptionCriteria(), + )->willReturn([ + new Subscription('id1'), + ])->shouldBeCalled(); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([])); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore->reveal(), + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engineCriteria = new SubscriptionEngineCriteria( + ids: ['id1'], + groups: ['group1'], + ); + + $engine->{$method}($engineCriteria); + } + + #[DataProvider('methodProvider')] + public function testWithLockableStore(string $method): void + { + $subscriber = new #[Subscriber('id1')] + class { + }; + + $subscriptionStore = $this->prophesize(LockableSubscriptionStore::class); + $subscriptionStore->inLock(Argument::type(Closure::class))->will( + /** @param array{Closure} $args */ + static fn (array $args): mixed => $args[0]() + )->shouldBeCalled(); + $subscriptionStore->find(Argument::any())->willReturn([])->shouldBeCalled(); + + $subscriptionStore->find( + new SubscriptionCriteria(), + )->willReturn([ + new Subscription('id1'), + ])->shouldBeCalled(); + + $subscriptionStore->remove(Argument::type(Subscription::class)); + $subscriptionStore->add(Argument::type(Subscription::class)); + + $streamableStore = $this->prophesize(Store::class); + $streamableStore->load($this->criteria())->willReturn(new ArrayStream([])); + + $engine = new DefaultSubscriptionEngine( + $streamableStore->reveal(), + $subscriptionStore->reveal(), + new MetadataSubscriberAccessorRepository([$subscriber]), + ); + + $engine->{$method}(); + } + + public static function methodProvider(): Generator + { + yield 'boot' => ['boot']; + yield 'run' => ['run']; + yield 'teardown' => ['teardown']; + yield 'remove' => ['remove']; + yield 'reactivate' => ['reactivate']; + yield 'subscriptions' => ['subscriptions']; + } + + private function criteria(int $fromIndex = 0): Criteria + { + return new Criteria(fromIndex: $fromIndex); + } +} diff --git a/tests/Unit/Projection/Projection/ErrorContextTest.php b/tests/Unit/Subscription/ErrorContextTest.php similarity index 93% rename from tests/Unit/Projection/Projection/ErrorContextTest.php rename to tests/Unit/Subscription/ErrorContextTest.php index eb6329ca8..28330915b 100644 --- a/tests/Unit/Projection/Projection/ErrorContextTest.php +++ b/tests/Unit/Subscription/ErrorContextTest.php @@ -2,10 +2,10 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Unit\Projection\Projection; +namespace Patchlevel\EventSourcing\Tests\Unit\Subscription; use Patchlevel\EventSourcing\Aggregate\CustomId; -use Patchlevel\EventSourcing\Projection\Projection\ThrowableToErrorContextTransformer; +use Patchlevel\EventSourcing\Subscription\ThrowableToErrorContextTransformer; use PHPUnit\Framework\TestCase; use RuntimeException; diff --git a/tests/Unit/Projection/RetryStrategy/ClockBasedRetryStrategyTest.php b/tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php similarity index 72% rename from tests/Unit/Projection/RetryStrategy/ClockBasedRetryStrategyTest.php rename to tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php index 420486dc4..d98d295c8 100644 --- a/tests/Unit/Projection/RetryStrategy/ClockBasedRetryStrategyTest.php +++ b/tests/Unit/Subscription/RetryStrategy/ClockBasedRetryStrategyTest.php @@ -2,19 +2,19 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Unit\Projection\RetryStrategy; +namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\RetryStrategy; use DateTimeImmutable; use Generator; use Patchlevel\EventSourcing\Clock\FrozenClock; -use Patchlevel\EventSourcing\Projection\Projection\Projection; -use Patchlevel\EventSourcing\Projection\Projection\ProjectionStatus; -use Patchlevel\EventSourcing\Projection\Projection\RunMode; -use Patchlevel\EventSourcing\Projection\RetryStrategy\ClockBasedRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RunMode; +use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Subscription; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; -/** @covers \Patchlevel\EventSourcing\Projection\RetryStrategy\ClockBasedRetryStrategy */ +/** @covers \Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy */ final class ClockBasedRetryStrategyTest extends TestCase { private ClockBasedRetryStrategy $strategy; @@ -31,11 +31,11 @@ public function setUp(): void #[DataProvider('attemptProvider')] public function testShouldRetry(int $attempt, int $seconds, bool $expected): void { - $projection = new Projection( + $subscription = new Subscription( 'test', 'default', RunMode::FromBeginning, - ProjectionStatus::Error, + Status::Error, 0, null, $attempt, @@ -46,7 +46,7 @@ public function testShouldRetry(int $attempt, int $seconds, bool $expected): voi self::assertEquals( $expected, - $this->strategy->shouldRetry($projection), + $this->strategy->shouldRetry($subscription), ); } diff --git a/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php b/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php new file mode 100644 index 000000000..fa12aacc7 --- /dev/null +++ b/tests/Unit/Subscription/RetryStrategy/NoRetryStrategyTest.php @@ -0,0 +1,22 @@ +shouldRetry( + new Subscription('test'), + )); + } +} diff --git a/tests/Unit/Projection/Projection/Store/ErrorContextTest.php b/tests/Unit/Subscription/Store/ErrorContextTest.php similarity index 80% rename from tests/Unit/Projection/Projection/Store/ErrorContextTest.php rename to tests/Unit/Subscription/Store/ErrorContextTest.php index fe3d29692..7bb9a391b 100644 --- a/tests/Unit/Projection/Projection/Store/ErrorContextTest.php +++ b/tests/Unit/Subscription/Store/ErrorContextTest.php @@ -2,13 +2,13 @@ declare(strict_types=1); -namespace Patchlevel\EventSourcing\Tests\Unit\Projection\Projection\Store; +namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Store; -use Patchlevel\EventSourcing\Projection\Projection\ThrowableToErrorContextTransformer; +use Patchlevel\EventSourcing\Subscription\ThrowableToErrorContextTransformer; use PHPUnit\Framework\TestCase; use RuntimeException; -/** @covers \Patchlevel\EventSourcing\Projection\Projection\ThrowableToErrorContextTransformer */ +/** @covers \Patchlevel\EventSourcing\Subscription\ThrowableToErrorContextTransformer */ final class ErrorContextTest extends TestCase { public function testWithoutPrevious(): void diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php new file mode 100644 index 000000000..d9506d6a3 --- /dev/null +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -0,0 +1,154 @@ +add($subscription); + + self::assertEquals($subscription, $store->get($id)); + self::assertEquals([$subscription], $store->find()); + } + + public function testAddDuplicated(): void + { + $this->expectException(SubscriptionAlreadyExists::class); + + $id = 'test'; + $subscription = new Subscription($id); + + $store = new InMemorySubscriptionStore([$subscription]); + $store->add($subscription); + } + + public function testUpdate(): void + { + $id = 'test'; + $subscription = new Subscription($id); + + $store = new InMemorySubscriptionStore([$subscription]); + + $store->update($subscription); + + self::assertEquals($subscription, $store->get($id)); + self::assertEquals([$subscription], $store->find()); + } + + public function testUpdateNotFound(): void + { + $this->expectException(SubscriptionNotFound::class); + + $id = 'test'; + $subscription = new Subscription($id); + + $store = new InMemorySubscriptionStore(); + + $store->update($subscription); + } + + public function testNotFound(): void + { + $this->expectException(SubscriptionNotFound::class); + + $store = new InMemorySubscriptionStore(); + $store->get('test'); + } + + public function testRemove(): void + { + $id = 'test'; + $subscription = new Subscription($id); + + $store = new InMemorySubscriptionStore([$subscription]); + + $store->remove($subscription); + + self::assertEquals([], $store->find()); + } + + public function testFind(): void + { + $subscription1 = new Subscription('1'); + $subscription2 = new Subscription('2'); + + $store = new InMemorySubscriptionStore([$subscription1, $subscription2]); + + self::assertEquals([$subscription1, $subscription2], $store->find()); + } + + public function testFindById(): void + { + $subscription1 = new Subscription('1'); + $subscription2 = new Subscription('2'); + + $store = new InMemorySubscriptionStore([$subscription1, $subscription2]); + + $criteria = new SubscriptionCriteria( + ids: ['1'], + ); + + self::assertEquals([$subscription1], $store->find($criteria)); + } + + public function testFindByGroup(): void + { + $subscription1 = new Subscription('1', group: 'group1'); + $subscription2 = new Subscription('2', group: 'group2'); + + $store = new InMemorySubscriptionStore([$subscription1, $subscription2]); + + $criteria = new SubscriptionCriteria( + groups: ['group1'], + ); + + self::assertEquals([$subscription1], $store->find($criteria)); + } + + public function testFindByStatus(): void + { + $subscription1 = new Subscription('1', status: Status::New); + $subscription2 = new Subscription('2', status: Status::Booting); + + $store = new InMemorySubscriptionStore([$subscription1, $subscription2]); + + $criteria = new SubscriptionCriteria( + status: [Status::New], + ); + + self::assertEquals([$subscription1], $store->find($criteria)); + } + + public function testFindByAll(): void + { + $subscription1 = new Subscription('1', group: 'group1', status: Status::New); + $subscription2 = new Subscription('2', group: 'group2', status: Status::Booting); + + $store = new InMemorySubscriptionStore([$subscription1, $subscription2]); + + $criteria = new SubscriptionCriteria( + ids: ['1'], + groups: ['group1'], + status: [Status::New], + ); + + self::assertEquals([$subscription1], $store->find($criteria)); + } +} diff --git a/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php b/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php new file mode 100644 index 000000000..dca218c24 --- /dev/null +++ b/tests/Unit/Subscription/Store/SubscriptionAlreadyExistsTest.php @@ -0,0 +1,24 @@ +getMessage(), + ); + + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php b/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php new file mode 100644 index 000000000..29c4e04c0 --- /dev/null +++ b/tests/Unit/Subscription/Store/SubscriptionCriteriaTest.php @@ -0,0 +1,20 @@ +ids); + } +} diff --git a/tests/Unit/Subscription/Store/SubscriptionNotFoundTest.php b/tests/Unit/Subscription/Store/SubscriptionNotFoundTest.php new file mode 100644 index 000000000..a4d456ccd --- /dev/null +++ b/tests/Unit/Subscription/Store/SubscriptionNotFoundTest.php @@ -0,0 +1,23 @@ +getMessage(), + ); + self::assertSame(0, $exception->getCode()); + } +} diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php new file mode 100644 index 000000000..d371b5018 --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php @@ -0,0 +1,46 @@ +all()); + self::assertNull($repository->get('foo')); + } + + public function testWithSubscriber(): void + { + $subscriber = new #[Subscriber('foo')] + class { + }; + $metadataFactory = new AttributeSubscriberMetadataFactory(); + + $repository = new MetadataSubscriberAccessorRepository( + [$subscriber], + $metadataFactory, + ); + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + $metadataFactory->metadata($subscriber::class), + ); + + self::assertEquals([$accessor], $repository->all()); + self::assertEquals($accessor, $repository->get('foo')); + } +} diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php new file mode 100644 index 000000000..2f95de93c --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php @@ -0,0 +1,206 @@ +metadata($subscriber::class), + ); + + self::assertEquals('profile', $accessor->id()); + } + + public function testGroup(): void + { + $subscriber = new #[Subscriber('profile')] + class { + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + self::assertEquals('default', $accessor->group()); + } + + public function testRunMode(): void + { + $subscriber = new #[Subscriber('profile')] + class { + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + self::assertEquals(RunMode::FromBeginning, $accessor->runMode()); + } + + public function testSubscribeMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(Message $message): void + { + } + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->subscribeMethods(ProfileCreated::class); + + self::assertEquals([ + $subscriber->onProfileCreated(...), + ], $result); + } + + public function testMultipleSubscribeMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(Message $message): void + { + } + + #[Subscribe(ProfileCreated::class)] + public function onFoo(Message $message): void + { + } + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->subscribeMethods(ProfileCreated::class); + + self::assertEquals([ + $subscriber->onProfileCreated(...), + $subscriber->onFoo(...), + ], $result); + } + + public function testSubscribeAllMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + #[Subscribe('*')] + public function onProfileCreated(Message $message): void + { + } + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->subscribeMethods(ProfileCreated::class); + + self::assertEquals([ + $subscriber->onProfileCreated(...), + ], $result); + } + + public function testSetupMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + #[Setup] + public function method(): void + { + } + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->setupMethod(); + + self::assertEquals($subscriber->method(...), $result); + } + + public function testNotSetupMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->setupMethod(); + + self::assertNull($result); + } + + public function testTeardownMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + #[Teardown] + public function method(): void + { + } + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->teardownMethod(); + + self::assertEquals($subscriber->method(...), $result); + } + + public function testNotTeardownMethod(): void + { + $subscriber = new #[Subscriber('profile')] + class { + }; + + $accessor = new MetadataSubscriberAccessor( + $subscriber, + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class), + ); + + $result = $accessor->teardownMethod(); + + self::assertNull($result); + } +} diff --git a/tests/Unit/Subscription/Subscriber/SubscriberHelperTest.php b/tests/Unit/Subscription/Subscriber/SubscriberHelperTest.php new file mode 100644 index 000000000..305da7a48 --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/SubscriberHelperTest.php @@ -0,0 +1,24 @@ +subscriberId($subscriber)); + } +} diff --git a/tests/Unit/Subscription/SubscriptionErrorTest.php b/tests/Unit/Subscription/SubscriptionErrorTest.php new file mode 100644 index 000000000..4cc5195f3 --- /dev/null +++ b/tests/Unit/Subscription/SubscriptionErrorTest.php @@ -0,0 +1,26 @@ +errorMessage); + self::assertSame(Status::Active, $error->previousStatus); + self::assertIsArray($error->errorContext); + } +} diff --git a/tests/Unit/Subscription/SubscriptionTest.php b/tests/Unit/Subscription/SubscriptionTest.php new file mode 100644 index 000000000..8f7f84dad --- /dev/null +++ b/tests/Unit/Subscription/SubscriptionTest.php @@ -0,0 +1,149 @@ +id()); + self::assertEquals(Status::New, $subscription->status()); + self::assertEquals(0, $subscription->position()); + self::assertTrue($subscription->isNew()); + self::assertFalse($subscription->isBooting()); + self::assertFalse($subscription->isActive()); + self::assertFalse($subscription->isError()); + self::assertFalse($subscription->isOutdated()); + } + + public function testBooting(): void + { + $subscription = new Subscription( + 'test', + ); + + $subscription->booting(); + + self::assertEquals(Status::Booting, $subscription->status()); + self::assertFalse($subscription->isNew()); + self::assertTrue($subscription->isBooting()); + self::assertFalse($subscription->isActive()); + self::assertFalse($subscription->isError()); + self::assertFalse($subscription->isOutdated()); + } + + public function testActive(): void + { + $subscription = new Subscription( + 'test', + ); + + $subscription->active(); + + self::assertEquals(Status::Active, $subscription->status()); + self::assertFalse($subscription->isNew()); + self::assertFalse($subscription->isBooting()); + self::assertTrue($subscription->isActive()); + self::assertFalse($subscription->isError()); + self::assertFalse($subscription->isOutdated()); + } + + public function testError(): void + { + $subscription = new Subscription( + 'test', + ); + + $exception = new RuntimeException('test'); + + $subscription->error($exception); + + self::assertEquals(Status::Error, $subscription->status()); + self::assertFalse($subscription->isNew()); + self::assertFalse($subscription->isBooting()); + self::assertFalse($subscription->isActive()); + self::assertTrue($subscription->isError()); + self::assertFalse($subscription->isOutdated()); + self::assertEquals( + new SubscriptionError( + 'test', + Status::New, + ThrowableToErrorContextTransformer::transform($exception), + ), + $subscription->subscriptionError(), + ); + } + + public function testOutdated(): void + { + $subscription = new Subscription( + 'test', + ); + + $subscription->outdated(); + + self::assertEquals(Status::Outdated, $subscription->status()); + self::assertFalse($subscription->isNew()); + self::assertFalse($subscription->isBooting()); + self::assertFalse($subscription->isActive()); + self::assertFalse($subscription->isError()); + self::assertTrue($subscription->isOutdated()); + } + + public function testChangePosition(): void + { + $subscription = new Subscription( + 'test', + ); + + $subscription->changePosition(10); + + self::assertEquals(10, $subscription->position()); + } + + public function testCanNotRetry(): void + { + $this->expectException(NoErrorToRetry::class); + + $subscription = new Subscription( + 'test', + ); + + $subscription->doRetry(); + } + + public function testDoRetry(): void + { + $subscription = new Subscription( + 'test', + 'default', + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('test', Status::New, []), + ); + + self::assertEquals(null, $subscription->retryAttempt()); + $subscription->doRetry(); + + self::assertEquals(1, $subscription->retryAttempt()); + $subscription->resetRetry(); + + self::assertEquals(null, $subscription->retryAttempt()); + } +}