From c7f4ba60f28c7e7e135710965615b60443507452 Mon Sep 17 00:00:00 2001 From: David Badura Date: Sun, 26 Apr 2026 11:06:35 +0200 Subject: [PATCH 01/12] refactor subscription engine --- docker-compose.yaml | 13 +- src/Subscription/Engine/Command/Boot.php | 21 ++ src/Subscription/Engine/Command/Command.php | 18 ++ src/Subscription/Engine/Command/Pause.php | 9 + .../Engine/Command/Reactivate.php | 9 + src/Subscription/Engine/Command/Refresh.php | 9 + src/Subscription/Engine/Command/Remove.php | 9 + src/Subscription/Engine/Command/Run.php | 21 ++ src/Subscription/Engine/Command/Setup.php | 20 ++ src/Subscription/Engine/Command/Teardown.php | 9 + src/Subscription/Engine/Event/OnCommand.php | 15 ++ .../Engine/Event/OnHandleMessage.php | 17 ++ .../Engine/Event/OnHandleMessageError.php | 21 ++ .../Engine/Event/OnHandleMessageSuccess.php | 19 ++ .../Engine/Event/OnProcessingFinished.php | 21 ++ src/Subscription/Engine/Event/OnResult.php | 17 ++ .../Engine/Handler/BootHandler.php | 221 ++++++++++++++++++ src/Subscription/Engine/Handler/Handler.php | 16 ++ .../Engine/Handler/PauseHandler.php | 60 +++++ .../Engine/Handler/ReactivateHandler.php | 93 ++++++++ .../Engine/Handler/RefreshHandler.php | 116 +++++++++ .../Engine/Handler/RemoveHandler.php | 127 ++++++++++ .../Engine/Handler/RunHandler.php | 180 ++++++++++++++ .../Engine/Handler/SetupHandler.php | 133 +++++++++++ .../Engine/Handler/TeardownHandler.php | 130 +++++++++++ .../LegacyWrapperSubscriptionEngine.php | 135 +++++++++++ .../Engine/Listener/BatchSubscriber.php | 164 +++++++++++++ .../Engine/Listener/DetachListener.php | 63 +++++ .../Engine/Listener/DiscoverListener.php | 91 ++++++++ .../Engine/Listener/FailListener.php | 82 +++++++ .../Engine/Listener/RetrySubscriber.php | 137 +++++++++++ src/Subscription/Engine/MessageProcessor.php | 125 ++++++++++ .../Engine/NextSubscriptionEngine.php | 212 +++++++++++++++++ src/Subscription/Engine/ProcessedResult.php | 5 +- src/Subscription/Engine/Result.php | 2 +- tests/bootstrap.php | 5 + 36 files changed, 2341 insertions(+), 4 deletions(-) create mode 100644 src/Subscription/Engine/Command/Boot.php create mode 100644 src/Subscription/Engine/Command/Command.php create mode 100644 src/Subscription/Engine/Command/Pause.php create mode 100644 src/Subscription/Engine/Command/Reactivate.php create mode 100644 src/Subscription/Engine/Command/Refresh.php create mode 100644 src/Subscription/Engine/Command/Remove.php create mode 100644 src/Subscription/Engine/Command/Run.php create mode 100644 src/Subscription/Engine/Command/Setup.php create mode 100644 src/Subscription/Engine/Command/Teardown.php create mode 100644 src/Subscription/Engine/Event/OnCommand.php create mode 100644 src/Subscription/Engine/Event/OnHandleMessage.php create mode 100644 src/Subscription/Engine/Event/OnHandleMessageError.php create mode 100644 src/Subscription/Engine/Event/OnHandleMessageSuccess.php create mode 100644 src/Subscription/Engine/Event/OnProcessingFinished.php create mode 100644 src/Subscription/Engine/Event/OnResult.php create mode 100644 src/Subscription/Engine/Handler/BootHandler.php create mode 100644 src/Subscription/Engine/Handler/Handler.php create mode 100644 src/Subscription/Engine/Handler/PauseHandler.php create mode 100644 src/Subscription/Engine/Handler/ReactivateHandler.php create mode 100644 src/Subscription/Engine/Handler/RefreshHandler.php create mode 100644 src/Subscription/Engine/Handler/RemoveHandler.php create mode 100644 src/Subscription/Engine/Handler/RunHandler.php create mode 100644 src/Subscription/Engine/Handler/SetupHandler.php create mode 100644 src/Subscription/Engine/Handler/TeardownHandler.php create mode 100644 src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php create mode 100644 src/Subscription/Engine/Listener/BatchSubscriber.php create mode 100644 src/Subscription/Engine/Listener/DetachListener.php create mode 100644 src/Subscription/Engine/Listener/DiscoverListener.php create mode 100644 src/Subscription/Engine/Listener/FailListener.php create mode 100644 src/Subscription/Engine/Listener/RetrySubscriber.php create mode 100644 src/Subscription/Engine/MessageProcessor.php create mode 100644 src/Subscription/Engine/NextSubscriptionEngine.php diff --git a/docker-compose.yaml b/docker-compose.yaml index 38249c8b5..2174d030d 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -1,4 +1,15 @@ +# docker run --rm -it --volume $PWD:/app --net="host" -w /app ghcr.io/patchlevel/php:8.5 services: + php: + image: ghcr.io/patchlevel/php:8.5 + volumes: + - .:/app + working_dir: /app + network_mode: host + tty: true + stdin_open: true + command: sleep infinity + postgres: image: postgres:alpine environment: @@ -13,4 +24,4 @@ services: - MYSQL_ALLOW_EMPTY_PASSWORD="yes" - MYSQL_DATABASE=eventstore ports: - - 3306:3306 \ No newline at end of file + - 3306:3306 diff --git a/src/Subscription/Engine/Command/Boot.php b/src/Subscription/Engine/Command/Boot.php new file mode 100644 index 000000000..acf6be1cf --- /dev/null +++ b/src/Subscription/Engine/Command/Boot.php @@ -0,0 +1,21 @@ +|null $ids + * @param list|null $groups + * @param positive-int|null $limit + */ + public function __construct( + array|null $ids = null, + array|null $groups = null, + public readonly int|null $limit = null, + ) { + parent::__construct($ids, $groups); + } +} diff --git a/src/Subscription/Engine/Command/Command.php b/src/Subscription/Engine/Command/Command.php new file mode 100644 index 000000000..032742561 --- /dev/null +++ b/src/Subscription/Engine/Command/Command.php @@ -0,0 +1,18 @@ +|null $ids + * @param list|null $groups + */ + public function __construct( + public readonly array|null $ids = null, + public readonly array|null $groups = null, + ) { + } +} diff --git a/src/Subscription/Engine/Command/Pause.php b/src/Subscription/Engine/Command/Pause.php new file mode 100644 index 000000000..28780d532 --- /dev/null +++ b/src/Subscription/Engine/Command/Pause.php @@ -0,0 +1,9 @@ +|null $ids + * @param list|null $groups + * @param positive-int|null $limit + */ + public function __construct( + array|null $ids = null, + array|null $groups = null, + public readonly int|null $limit = null, + ) { + parent::__construct($ids, $groups); + } +} diff --git a/src/Subscription/Engine/Command/Setup.php b/src/Subscription/Engine/Command/Setup.php new file mode 100644 index 000000000..83ef8617f --- /dev/null +++ b/src/Subscription/Engine/Command/Setup.php @@ -0,0 +1,20 @@ +|null $ids + * @param list|null $groups + */ + public function __construct( + array|null $ids = null, + array|null $groups = null, + public readonly bool $skipBooting = false, + ) { + parent::__construct($ids, $groups); + } +} diff --git a/src/Subscription/Engine/Command/Teardown.php b/src/Subscription/Engine/Command/Teardown.php new file mode 100644 index 000000000..e144b032c --- /dev/null +++ b/src/Subscription/Engine/Command/Teardown.php @@ -0,0 +1,9 @@ + + */ +final class BootHandler implements Handler +{ + public function __construct( + private readonly MessageLoader $messageLoader, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly MessageProcessor $messageProcessor, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): ProcessedResult + { + $this->logger?->info( + 'Subscription Engine: Start booting.', + ); + + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Booting], + ), + function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions in booting status, finish booting.'); + + return new ProcessedResult(0, true); + } + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if ($subscriber) { + continue; + } + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found, skipped.', + $subscription->id(), + ), + ); + + $subscriptions->remove($subscription); + } + + $startIndex = $subscriptions->lowestPosition(); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Event stream is processed for booting from position %s.', + $startIndex, + ), + ); + + /** @var list $errors */ + $errors = []; + $stream = null; + $messageCounter = 0; + $lastIndex = null; + + try { + $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); + + foreach ($stream as $index => $message) { + $messageCounter++; + $lastIndex = $index; + + foreach ($subscriptions as $subscription) { + if ($subscription->position() >= $index) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue booting.', + $subscription->id(), + $subscription->position(), + $index, + ), + ); + + continue; + } + + $error = $this->messageProcessor->process($index, $message, $subscription); + + if (!$error) { + continue; + } + + $errors[] = $error; + + $subscriptions->remove($subscription); + + if (count($subscriptions) === 0) { + $this->logger?->info( + 'Subscription Engine: No subscriptions in booting status, finish booting.', + ); + + break 2; + } + } + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Current event stream position for booting: %s', + $index, + ), + ); + + if ($command->limit !== null && $messageCounter >= $command->limit) { + $this->logger?->info( + sprintf( + 'Subscription Engine: Message limit (%d) reached, finish booting.', + $command->limit, + ), + ); + + $this->eventDispatcher->dispatch( + new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_LIMIT_REACHED, + $messageCounter, + ), + ); + + return new ProcessedResult( + $messageCounter, + false, + $errors, + ); + } + } + + $this->eventDispatcher->dispatch( + new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_STREAM_ENDED, + $messageCounter, + ), + ); + } finally { + $stream?->close(); + + if ($lastIndex !== null && $messageCounter > 0) { + foreach ($subscriptions as $subscription) { + $this->subscriptionManager->update($subscription); + } + } + } + + $this->logger?->debug('Subscription Engine: End of stream for booting has been reached.'); + + foreach ($subscriptions as $subscription) { + if ($subscription->status() !== Status::Booting) { + continue; + } + + if ($subscription->runMode() === RunMode::Once) { + $subscription->finished(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', + $subscription->id(), + )); + + continue; + } + + $subscription->active(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" has been set to active after booting.', + $subscription->id(), + )); + } + + $this->logger?->info('Subscription Engine: Finish booting.'); + + return new ProcessedResult( + $messageCounter, + true, + $errors, + ); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/Handler.php b/src/Subscription/Engine/Handler/Handler.php new file mode 100644 index 000000000..cab82d44a --- /dev/null +++ b/src/Subscription/Engine/Handler/Handler.php @@ -0,0 +1,16 @@ + + */ +final class PauseHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [ + Status::Active, + Status::Booting, + Status::Error, + ], + ), + function (SubscriptionCollection $subscriptions): Result { + /** @var Subscription $subscription */ + foreach ($subscriptions as $subscription) { + $subscription->pause(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" is paused.', + $subscription->id(), + )); + } + + return new Result(); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/ReactivateHandler.php b/src/Subscription/Engine/Handler/ReactivateHandler.php new file mode 100644 index 000000000..170ae3549 --- /dev/null +++ b/src/Subscription/Engine/Handler/ReactivateHandler.php @@ -0,0 +1,93 @@ + + */ +final class ReactivateHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [ + Status::Error, + Status::Failed, + Status::Detached, + Status::Paused, + Status::Finished, + ], + ), + function (SubscriptionCollection $subscriptions): Result { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found, skipped.', + $subscription->id(), + ), + ); + + continue; + } + + $error = $subscription->subscriptionError(); + + if ($error) { + $subscription->doRetry(); + $subscription->resetRetry(); + + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); + + continue; + } + + $subscription->active(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); + } + + return new Result(); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php new file mode 100644 index 000000000..5931a5524 --- /dev/null +++ b/src/Subscription/Engine/Handler/RefreshHandler.php @@ -0,0 +1,116 @@ + + */ +final class RefreshHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + )); + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + continue; + } + + $changed = false; + + if ($subscription->runMode() !== $subscriber->metadata()->runMode) { + $changed = true; + $oldRunMode = $subscription->runMode(); + $subscription->changeRunMode($subscriber->metadata()->runMode); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" run mode changed from "%s" to "%s".', + $subscription->id(), + $oldRunMode->value, + $subscription->runMode()->value, + ), + ); + } + + if ($subscription->group() !== $subscriber->metadata()->group) { + $changed = true; + $oldGroup = $subscription->group(); + $subscription->changeGroup($subscriber->metadata()->group); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" group changed from "%s" to "%s".', + $subscription->id(), + $oldGroup, + $subscription->group(), + ), + ); + } + + $cleanupTasks = $this->cleanupTasks($subscriber); + + if ($subscription->cleanupTasks() !== $cleanupTasks) { + $changed = true; + $subscription->replaceCleanupTasks($cleanupTasks); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" cleanup tasks changed.', + $subscription->id(), + ), + ); + } + + if (!$changed) { + continue; + } + + $this->subscriptionManager->update($subscription); + } + + $this->subscriptionManager->flush(); + + return new Result(); + } + + /** @return list|null */ + private function cleanupTasks(MetadataSubscriberAccessor $subscriber): array|null + { + $method = $subscriber->cleanupMethod(); + + if (!$method) { + return null; + } + + return array_values([...$method()]); + } +} diff --git a/src/Subscription/Engine/Handler/RemoveHandler.php b/src/Subscription/Engine/Handler/RemoveHandler.php new file mode 100644 index 000000000..f83e54734 --- /dev/null +++ b/src/Subscription/Engine/Handler/RemoveHandler.php @@ -0,0 +1,127 @@ + + */ +final class RemoveHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly Cleaner|null $cleaner = null, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + ), + function (SubscriptionCollection $subscriptions): Result { + /** @var list $errors */ + $errors = []; + + foreach ($subscriptions as $subscription) { + if ($subscription->isNew()) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + ), + ); + + continue; + } + + if ($subscription->hasCleanupTasks()) { + $error = $this->cleanup($subscription, true); + + if ($error) { + $errors[] = $error; + } + + continue; + } + + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed without a suitable subscriber.', + $subscription->id(), + ), + ); + + continue; + } + + $teardownMethod = $subscriber->teardownMethod(); + + if (!$teardownMethod) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), + ); + + continue; + } + + try { + $teardownMethod(); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscriber "%s" teardown method could not be executed: %s', + $subscriber::class, + $e->getMessage(), + ), + ); + + $errors[] = new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), + ); + } + + return new Result($errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php new file mode 100644 index 000000000..bde40408a --- /dev/null +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -0,0 +1,180 @@ + + */ +final class RunHandler implements Handler +{ + public function __construct( + private readonly MessageLoader $messageLoader, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly MessageProcessor $messageProcessor, + private readonly EventDispatcherInterface $eventDispatcher, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): ProcessedResult + { + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Active], + ), + function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions to process, finish processing.'); + + return new ProcessedResult(0, true); + } + + $startIndex = $subscriptions->lowestPosition(); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Event stream is processed from position %d.', + $startIndex, + ), + ); + + /** @var list $errors */ + $errors = []; + $stream = null; + $messageCounter = 0; + $lastIndex = null; + + try { + $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); + + foreach ($stream as $index => $message) { + $messageCounter++; + $lastIndex = $index; + + foreach ($subscriptions as $subscription) { + if ($subscription->position() >= $index) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue processing.', + $subscription->id(), + $subscription->position(), + $index, + ), + ); + + continue; + } + + $error = $this->messageProcessor->process($index, $message, $subscription); + + if (!$error) { + continue; + } + + $errors[] = $error; + + $subscriptions->remove($subscription); + + if (count($subscriptions) === 0) { + $this->logger?->info( + 'Subscription Engine: No subscriptions in booting status, finish booting.', + ); + + break 2; + } + } + + $this->logger?->debug(sprintf( + 'Subscription Engine: Current event stream position: %s', + $index, + )); + + if ($command->limit !== null && $messageCounter >= $command->limit) { + $this->logger?->info( + sprintf( + 'Subscription Engine: Message limit (%d) reached, finish processing.', + $command->limit, + ), + ); + + $this->eventDispatcher->dispatch( + new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_LIMIT_REACHED, + $messageCounter, + ), + ); + + return new ProcessedResult($messageCounter, false, $errors); + } + } + + $this->eventDispatcher->dispatch( + new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_STREAM_ENDED, + $messageCounter, + ), + ); + } finally { + $stream?->close(); + + if ($lastIndex !== null && $messageCounter > 0) { + foreach ($subscriptions as $subscription) { + $this->subscriptionManager->update($subscription); + } + } + } + + foreach ($subscriptions as $subscription) { + if ($subscription->runMode() !== RunMode::Once) { + continue; + } + + $subscription->finished(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', + $subscription->id(), + )); + } + + $this->logger?->info( + sprintf( + 'Subscription Engine: End of stream on position "%d" has been reached, finish processing.', + $lastIndex, + ), + ); + + return new ProcessedResult($messageCounter, true, $errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php new file mode 100644 index 000000000..f931d850f --- /dev/null +++ b/src/Subscription/Engine/Handler/SetupHandler.php @@ -0,0 +1,133 @@ + + */ +final class SetupHandler implements Handler +{ + public function __construct( + private readonly MessageLoader $messageLoader, + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + $this->logger?->info( + 'Subscription Engine: Start to setup.', + ); + + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::New], + ), + function (SubscriptionCollection $subscriptions) use ($command): Result { + if (count($subscriptions) === 0) { + $this->logger?->info('Subscription Engine: No subscriptions to setup, finish setup.'); + + return new Result(); + } + + /** @var list $errors */ + $errors = []; + + $latestIndex = $this->messageLoader->lastIndex(); + + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + throw SubscriberNotFound::forSubscriptionId($subscription->id()); + } + + $setupMethod = $subscriber->setupMethod(); + + if (!$setupMethod) { + if ($subscription->runMode() === RunMode::FromNow) { + $subscription->changePosition($latestIndex); + $subscription->active(); + } else { + $command->skipBooting ? $subscription->active() : $subscription->booting(); + } + + $this->subscriptionManager->update($subscription); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no setup method, set to %s.', + $subscriber::class, + $subscription->id(), + $subscription->runMode() === RunMode::FromNow || $command->skipBooting ? 'active' : 'booting', + )); + + continue; + } + + try { + $setupMethod(); + + if ($subscription->runMode() === RunMode::FromNow) { + $subscription->changePosition($latestIndex); + $subscription->active(); + } else { + $command->skipBooting ? $subscription->active() : $subscription->booting(); + } + + $this->subscriptionManager->update($subscription); + + $this->logger?->debug(sprintf( + 'Subscription Engine: For Subscriber "%s" for "%s" the setup method has been executed, set to %s.', + $subscriber::class, + $subscription->id(), + $subscription->runMode() === RunMode::FromNow || $command->skipBooting ? 'active' : 'booting', + )); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has an error in the setup method: %s', + $subscriber::class, + $subscription->id(), + $e->getMessage(), + )); + + $this->handleError($subscription, $e); + + $errors[] = new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + } + + return new Result($errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php new file mode 100644 index 000000000..d7a40ef93 --- /dev/null +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -0,0 +1,130 @@ + + */ +final class TeardownHandler implements Handler +{ + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function __invoke(Command $command): Result + { + $this->logger?->info('Subscription Engine: Start teardown detached subscriptions.'); + + return $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Detached], + ), + function (SubscriptionCollection $subscriptions): Result { + /** @var list $errors */ + $errors = []; + + foreach ($subscriptions as $subscription) { + if ($subscription->hasCleanupTasks()) { + $error = $this->cleanup($subscription); + + if ($error) { + $errors[] = $error; + } + + continue; + } + + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + $this->logger?->warning( + sprintf( + 'Subscription Engine: Subscriber for "%s" to teardown or cleanup not found, skipped.', + $subscription->id(), + ), + ); + + continue; + } + + $teardownMethod = $subscriber->teardownMethod(); + + if (!$teardownMethod) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" has no teardown method and was immediately removed.', + $subscriber::class, + $subscription->id(), + ), + ); + + continue; + } + + try { + $teardownMethod(); + + $this->logger?->debug(sprintf( + 'Subscription Engine: For Subscriber "%s" for "%s" the teardown method has been executed and is now prepared to be removed.', + $subscriber::class, + $subscription->id(), + )); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscription "%s" for "%s" has an error in the teardown method, skipped: %s', + $subscriber::class, + $subscription->id(), + $e->getMessage(), + ), + ); + + $errors[] = new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + + continue; + } + + $this->subscriptionManager->remove($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + ), + ); + } + + $this->logger?->info('Subscription Engine: Finish teardown.'); + + return new Result($errors); + }, + ); + } +} diff --git a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php new file mode 100644 index 000000000..b9d2a3793 --- /dev/null +++ b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php @@ -0,0 +1,135 @@ +engine = new NextSubscriptionEngine( + $messageLoader, + $subscriptionStore, + $subscriberRepository, + $retryStrategyRepository, + $logger, + $cleaner, + ); + } + + public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Setup( + $criteria->ids, + $criteria->groups, + )); + } + + public function boot( + SubscriptionEngineCriteria|null $criteria = null, + int|null $limit = null, + ): ProcessedResult { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Boot( + $criteria->ids, + $criteria->groups, + $limit, + )); + } + + public function run( + SubscriptionEngineCriteria|null $criteria = null, + int|null $limit = null, + ): ProcessedResult { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Run( + $criteria->ids, + $criteria->groups, + $limit, + )); + } + + public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Teardown( + $criteria->ids, + $criteria->groups, + )); + } + + public function remove(SubscriptionEngineCriteria|null $criteria = null): Result + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Remove( + $criteria->ids, + $criteria->groups, + )); + } + + public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Reactivate( + $criteria->ids, + $criteria->groups, + )); + } + + public function pause(SubscriptionEngineCriteria|null $criteria = null): Result + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Pause( + $criteria->ids, + $criteria->groups, + )); + } + + public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->engine->run(new Refresh( + $criteria->ids, + $criteria->groups, + )); + } + + /** @return list */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array + { + return $this->engine->subscriptions($criteria); + } +} diff --git a/src/Subscription/Engine/Listener/BatchSubscriber.php b/src/Subscription/Engine/Listener/BatchSubscriber.php new file mode 100644 index 000000000..298c55804 --- /dev/null +++ b/src/Subscription/Engine/Listener/BatchSubscriber.php @@ -0,0 +1,164 @@ + */ + private array $batching = []; + + public function __construct( + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function onCommand(OnCommand $event): void + { + $this->batching = []; + } + + public function onHandleMessage(OnHandleMessage $event): void + { + $subscriberId = $event->subscription->id(); + + if (isset($this->batching[$subscriberId])) { + return; + } + + $subscriber = $this->subscriberRepository->get($subscriberId); + + if (!$subscriber) { + return; + } + + $realSubscriber = $subscriber->subscriber(); + + if (!$realSubscriber instanceof BatchableSubscriber) { + return; + } + + $this->batching[$subscriberId] = $realSubscriber; + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" starts a new batch.', + $subscriberId, + )); + + try { + $realSubscriber->beginBatch(); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the begin batch method: %s', + $subscriberId, + $e->getMessage(), + )); + + throw $e; + } + } + + public function onHandleMessageSuccess(OnHandleMessageSuccess $event): void + { + $subscriberId = $event->subscription->id(); + + if (!isset($this->batching[$subscriberId])) { + return; + } + + if (!$this->shouldCommitBatch($event->subscription)) { + $event->shouldChangePosition = false; + + return; + } + + $subscriber = $this->batching[$subscriberId]; + unset($this->batching[$subscriberId]); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" commits the batch.', + $subscriberId, + )); + + try { + $subscriber->commitBatch(); + $event->shouldChangePosition = true; + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the commit batch method: %s', + $subscriberId, + $e->getMessage(), + )); + + throw $e; + } + } + + public function onResult(OnResult $event): void + { + } + + private function shouldCommitBatch(Subscription $subscription): bool + { + if (!isset($this->batching[$subscription->id()])) { + return false; + } + + return $this->batching[$subscription->id()]->forceCommit(); + } + + public function onError(OnHandleMessageError $event): void + { + $subscriptionId = $event->subscription->id(); + + if (!isset($this->batching[$subscriptionId])) { + return; + } + + $subscriber = $this->batching[$subscriptionId]; + + unset($this->batching[$subscriptionId]); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" rollback the batch.', + $subscriptionId, + )); + + try { + $subscriber->rollbackBatch(); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the rollback batch method: %s', + $subscriptionId, + $e->getMessage(), + )); + } + } + + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => 'onCommand', + OnHandleMessage::class => 'onHandleMessage', + OnHandleMessageSuccess::class => 'onHandleMessageSuccess', + OnHandleMessageError::class => 'onError', + ]; + } +} diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php new file mode 100644 index 000000000..930db2c55 --- /dev/null +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -0,0 +1,63 @@ +command; + + if (!$command instanceof Run) { + return; + } + + $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Active, Status::Paused, Status::Finished], + ), + function (SubscriptionCollection $subscriptions): void { + foreach ($subscriptions as $subscription) { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if ($subscriber) { + continue; + } + + $subscription->detached(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found and has been marked as detached.', + $subscription->id(), + ), + ); + } + }, + ); + } +} diff --git a/src/Subscription/Engine/Listener/DiscoverListener.php b/src/Subscription/Engine/Listener/DiscoverListener.php new file mode 100644 index 000000000..56f9d16d3 --- /dev/null +++ b/src/Subscription/Engine/Listener/DiscoverListener.php @@ -0,0 +1,91 @@ +command; + + // todo define when to discover + + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( + // ids: $command->ids, + // groups: $command->groups, + )); + + $latestIndex = null; + + foreach ($this->subscriberRepository->all() as $subscriber) { + foreach ($subscriptions as $subscription) { + if ($subscription->id() === $subscriber->metadata()->id) { + continue 2; + } + } + + $subscription = new Subscription( + $subscriber->metadata()->id, + $subscriber->metadata()->group, + $subscriber->metadata()->runMode, + cleanupTasks: $this->cleanupTasks($subscriber), + ); + + if ($subscriber->setupMethod() === null && $subscriber->metadata()->runMode === RunMode::FromNow) { + if ($latestIndex === null) { + $latestIndex = $this->messageLoader->lastIndex(); + } + + $subscription->changePosition($latestIndex); + $subscription->active(); + } + + $this->subscriptionManager->add($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: New Subscriber "%s" was found and added to the subscription store.', + $subscriber->metadata()->id, + ), + ); + } + + $this->subscriptionManager->flush(); + } + + /** @return list|null */ + private function cleanupTasks(MetadataSubscriberAccessor $subscriber): array|null + { + $method = $subscriber->cleanupMethod(); + + if (!$method) { + return null; + } + + return array_values([...$method()]); + } +} diff --git a/src/Subscription/Engine/Listener/FailListener.php b/src/Subscription/Engine/Listener/FailListener.php new file mode 100644 index 000000000..beff60b2b --- /dev/null +++ b/src/Subscription/Engine/Listener/FailListener.php @@ -0,0 +1,82 @@ +failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + $subscriber = $this->subscriber($subscription->id()); + + if (!$subscriber) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + if ($subscriber->subscriber() instanceof BatchableSubscriber) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + $failedMethod = $subscriber->failedMethod(); + + if (!$failedMethod) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + try { + $failedMethod($message, $throwable); + $subscription->changePosition($index); + $subscription->resetRetry(); + + $this->subscriptionManager->update($subscription); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the failed method: %s', + $subscription->id(), + $e->getMessage(), + )); + + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + } + } +} diff --git a/src/Subscription/Engine/Listener/RetrySubscriber.php b/src/Subscription/Engine/Listener/RetrySubscriber.php new file mode 100644 index 000000000..4c0c7400b --- /dev/null +++ b/src/Subscription/Engine/Listener/RetrySubscriber.php @@ -0,0 +1,137 @@ +command; + + $status = match ($command::class) { + Setup::class => Status::New, + Boot::class => Status::Booting, + Run::class => Status::Active, + default => null, + }; + + if ($status === null) { + return; + } + + $this->subscriptionManager->findForUpdate( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Error], + ), + function (SubscriptionCollection $subscriptions) use ($status): void { + /** @var Subscription $subscription */ + foreach ($subscriptions as $subscription) { + $error = $subscription->subscriptionError(); + + if ($error === null) { + continue; + } + + if ($error->previousStatus !== $status) { + continue; + } + + if (!$this->retryStrategy($subscription)->shouldRetry($subscription)) { + continue; + } + + $subscription->doRetry(); + $this->subscriptionManager->update($subscription); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Retry subscription "%s" (%d) and set back to %s.', + $subscription->id(), + $subscription->retryAttempt(), + $subscription->status()->value, + ), + ); + } + }, + ); + } + + public function onHandleMessageError(OnHandleMessageError $event): void + { + $retryStrategy = $this->retryStrategy($event->subscription); + + if (!$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($event->subscription)) { + $event->subscription->error($event->throwable); + $this->subscriptionManager->update($event->subscription); + + return; + } + + $event->transitionToFailed = true; + } + + public function onSuccessHandleMessage(OnHandleMessageSuccess $event): void + { + $event->subscription->resetRetry(); + } + + private function retryStrategy(Subscription $subscription): RetryStrategy + { + $subscriber = $this->subscriberRepository->get($subscription->id()); + + if (!$subscriber instanceof MetadataSubscriberAccessor) { + return $this->retryStrategyRepository->getDefaultRetryStrategy(); + } + + $retryStrategy = $subscriber->metadata()->retryStrategy; + + if ($retryStrategy === null) { + return $this->retryStrategyRepository->getDefaultRetryStrategy(); + } + + return $this->retryStrategyRepository->get($retryStrategy); + } + + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => ['onCommand', 16], + OnHandleMessageError::class => 'onHandleMessageError', + OnHandleMessageSuccess::class => 'onSuccessHandleMessage', + ]; + } +} diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php new file mode 100644 index 000000000..6ef42739b --- /dev/null +++ b/src/Subscription/Engine/MessageProcessor.php @@ -0,0 +1,125 @@ +subscriberRepository->get($subscription->id()); + + if (!$subscriber) { + throw SubscriberNotFound::forSubscriptionId($subscription->id()); + } + + $subscribeMethods = $subscriber->subscribeMethods($message->event()::class); + + if ($subscribeMethods === []) { + if (!isset($this->batching[$subscription->id()])) { + $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 null; + } + + try { + $event = new OnHandleMessage( + $subscription, + $message, + ); + + $this->eventDispatcher->dispatch($event); + } catch (Throwable $e) { + return new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + try { + foreach ($subscribeMethods as $subscribeMethod) { + $subscribeMethod($message); + } + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', + $subscriber::class, + $subscription->id(), + $message->event()::class, + $e->getMessage(), + ), + ); + + $this->eventDispatcher->dispatch( + new OnHandleMessageError( + $subscription, + $e, + $message, + $index, + ), + ); + + return new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + $event = new OnHandleMessageSuccess( + $subscription, + $message, + $index, + ); + + $this->eventDispatcher->dispatch($event); + + if ($event->shouldChangePosition) { + $subscription->changePosition($index); + } + + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', + $subscriber::class, + $subscription->id(), + $message->event()::class, + ), + ); + + return null; + } +} diff --git a/src/Subscription/Engine/NextSubscriptionEngine.php b/src/Subscription/Engine/NextSubscriptionEngine.php new file mode 100644 index 000000000..ba08b86e5 --- /dev/null +++ b/src/Subscription/Engine/NextSubscriptionEngine.php @@ -0,0 +1,212 @@ +, Handler> */ + private readonly array $handlers; + + public function __construct( + private readonly MessageLoader $messageLoader, + SubscriptionStore $subscriptionStore, + private readonly SubscriberAccessorRepository $subscriberRepository, + RetryStrategyRepository|null $retryStrategyRepository = null, + private readonly LoggerInterface|null $logger = null, + private readonly Cleaner|null $cleaner = null, + private readonly EventDispatcherInterface $eventDispatcher = new EventDispatcher(), + ) { + $this->subscriptionManager = new SubscriptionManager($subscriptionStore); + + if ($retryStrategyRepository instanceof RetryStrategyRepository) { + $this->retryStrategyRepository = $retryStrategyRepository; + } else { + $this->retryStrategyRepository = new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + } + + $messageProcessor = new MessageProcessor( + $this->subscriberRepository, + $this->eventDispatcher, + $this->logger, + ); + + $this->handlers = [ + Boot::class => new BootHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $messageProcessor, + $this->eventDispatcher, + $this->logger, + ), + Pause::class => new PauseHandler( + $this->subscriptionManager, + $this->logger, + ), + Reactivate::class => new ReactivateHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Refresh::class => new RefreshHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Remove::class => new RemoveHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->cleaner, + $this->logger, + ), + Run::class => new RunHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $messageProcessor, + $this->eventDispatcher, + $this->logger, + ), + Setup::class => new SetupHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Teardown::class => new TeardownHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + ]; + + $this->eventDispatcher->addListener( + OnCommand::class, + new DiscoverListener( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + 64, + ); + + $this->eventDispatcher->addSubscriber( + new RetrySubscriber( + $this->subscriptionManager, + $this->subscriberRepository, + $this->retryStrategyRepository, + $this->logger, + ), + ); + + $this->eventDispatcher->addSubscriber( + new BatchSubscriber( + $this->subscriberRepository, + $this->logger, + ), + ); + + $this->eventDispatcher->addListener( + OnCommand::class, + new DetachListener( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + 32, + ); + } + + public function run(Command $command): Result + { + if ($this->processing) { + throw new AlreadyProcessing(); + } + + $this->processing = true; + + try { + $handler = $this->handlers[$command::class] ?? null; + + if ($handler === null) { + throw new InvalidArgumentException('No handler found for command: ' . $command::class); + } + + $event = new OnCommand($command); + $this->eventDispatcher->dispatch($event); + + $result = $handler($command); + + $event = new OnResult($command, $result); + $this->eventDispatcher->dispatch($event); + + return $result; + } finally { + $this->processing = false; + } + } + + /** @return list */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array + { + $criteria ??= new SubscriptionEngineCriteria(); + + // todo dispatch event for discover + + return $this->subscriptionManager->find( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + ), + ); + } +} diff --git a/src/Subscription/Engine/ProcessedResult.php b/src/Subscription/Engine/ProcessedResult.php index d34f6c97d..be31a7f89 100644 --- a/src/Subscription/Engine/ProcessedResult.php +++ b/src/Subscription/Engine/ProcessedResult.php @@ -4,13 +4,14 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -final class ProcessedResult +final class ProcessedResult extends Result { /** @param list $errors */ public function __construct( public readonly int $processedMessages, public readonly bool $finished = false, - public readonly array $errors = [], + array $errors = [], ) { + parent::__construct($errors); } } diff --git a/src/Subscription/Engine/Result.php b/src/Subscription/Engine/Result.php index d644bb17d..3b0207337 100644 --- a/src/Subscription/Engine/Result.php +++ b/src/Subscription/Engine/Result.php @@ -4,7 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -final class Result +class Result { /** @param list $errors */ public function __construct( diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 06fdcb453..72d9b9bca 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,4 +2,9 @@ declare(strict_types=1); +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\LegacyWrapperSubscriptionEngine; + require __DIR__ . '/../vendor/autoload.php'; + +class_alias(LegacyWrapperSubscriptionEngine::class, DefaultSubscriptionEngine::class); From 11946009c477573607c6c78eb7a0a90546aef725 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 22 May 2026 00:32:15 +0200 Subject: [PATCH 02/12] finish implementation --- src/Subscription/Engine/CleanupRunner.php | 73 +++++++++++++++++++ .../Engine/Event/OnProcessingFinished.php | 5 ++ .../Engine/Event/OnSubscriptions.php | 15 ++++ .../Engine/Handler/BootHandler.php | 26 ++++--- .../Engine/Handler/RemoveHandler.php | 6 +- .../Engine/Handler/RunHandler.php | 26 ++++--- .../Engine/Handler/SetupHandler.php | 21 ++++++ .../Engine/Handler/TeardownHandler.php | 5 +- .../LegacyWrapperSubscriptionEngine.php | 1 + .../Engine/Listener/BatchSubscriber.php | 47 ++++++++++-- .../Engine/Listener/DiscoverListener.php | 29 ++++++-- .../Engine/Listener/FailListener.php | 22 +++++- src/Subscription/Engine/MessageProcessor.php | 20 ++++- .../Engine/NextSubscriptionEngine.php | 27 +++++-- 14 files changed, 269 insertions(+), 54 deletions(-) create mode 100644 src/Subscription/Engine/CleanupRunner.php create mode 100644 src/Subscription/Engine/Event/OnSubscriptions.php diff --git a/src/Subscription/Engine/CleanupRunner.php b/src/Subscription/Engine/CleanupRunner.php new file mode 100644 index 000000000..d32e99224 --- /dev/null +++ b/src/Subscription/Engine/CleanupRunner.php @@ -0,0 +1,73 @@ +cleaner) { + throw new CleanerNotConfigured(); + } + + try { + $this->cleaner->cleanup($subscription); + + $this->logger?->debug( + sprintf( + 'Subscription Engine: For Subscription "%s" the cleanup tasks have been executed.', + $subscription->id(), + ), + ); + } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscription "%s" has an error in the cleanup tasks: %s', + $subscription->id(), + $e->getMessage(), + ), + ); + + if ($force) { + $this->subscriptionManager->remove($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + )); + } + + return new Error( + $subscription->id(), + $e->getMessage(), + $e, + ); + } + + $this->subscriptionManager->remove($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" removed.', + $subscription->id(), + )); + + return null; + } +} diff --git a/src/Subscription/Engine/Event/OnProcessingFinished.php b/src/Subscription/Engine/Event/OnProcessingFinished.php index 2eb5c3a9d..a484565cb 100644 --- a/src/Subscription/Engine/Event/OnProcessingFinished.php +++ b/src/Subscription/Engine/Event/OnProcessingFinished.php @@ -5,17 +5,22 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Event; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Error; final class OnProcessingFinished { public const REASON_STREAM_ENDED = 'stream-ended'; public const REASON_LIMIT_REACHED = 'limit-reached'; + /** @var list */ + public array $errors = []; + /** @param self::REASON_* $reason */ public function __construct( public readonly Command $command, public readonly string $reason, public readonly int $processed, + public readonly int|null $lastIndex = null, ) { } } diff --git a/src/Subscription/Engine/Event/OnSubscriptions.php b/src/Subscription/Engine/Event/OnSubscriptions.php new file mode 100644 index 000000000..3b0992c04 --- /dev/null +++ b/src/Subscription/Engine/Event/OnSubscriptions.php @@ -0,0 +1,15 @@ +eventDispatcher->dispatch( - new OnProcessingFinished( - $command, - OnProcessingFinished::REASON_LIMIT_REACHED, - $messageCounter, - ), + $limitEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_LIMIT_REACHED, + $messageCounter, + $lastIndex, ); + $this->eventDispatcher->dispatch($limitEvent); + $errors = array_merge($errors, $limitEvent->errors); return new ProcessedResult( $messageCounter, @@ -163,13 +164,14 @@ function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult } } - $this->eventDispatcher->dispatch( - new OnProcessingFinished( - $command, - OnProcessingFinished::REASON_STREAM_ENDED, - $messageCounter, - ), + $finishedEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_STREAM_ENDED, + $messageCounter, + $lastIndex, ); + $this->eventDispatcher->dispatch($finishedEvent); + $errors = array_merge($errors, $finishedEvent->errors); } finally { $stream?->close(); diff --git a/src/Subscription/Engine/Handler/RemoveHandler.php b/src/Subscription/Engine/Handler/RemoveHandler.php index f83e54734..4e43859e7 100644 --- a/src/Subscription/Engine/Handler/RemoveHandler.php +++ b/src/Subscription/Engine/Handler/RemoveHandler.php @@ -4,7 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; -use Patchlevel\EventSourcing\Subscription\Cleanup\Cleaner; +use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; use Patchlevel\EventSourcing\Subscription\Engine\Error; @@ -28,7 +28,7 @@ final class RemoveHandler implements Handler public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, - private readonly Cleaner|null $cleaner = null, + private readonly CleanupRunner $cleanupRunner, private readonly LoggerInterface|null $logger = null, ) { } @@ -59,7 +59,7 @@ function (SubscriptionCollection $subscriptions): Result { } if ($subscription->hasCleanupTasks()) { - $error = $this->cleanup($subscription, true); + $error = $this->cleanupRunner->cleanup($subscription, true); if ($error) { $errors[] = $error; diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index bde40408a..3441d34ea 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -123,25 +123,27 @@ function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult ), ); - $this->eventDispatcher->dispatch( - new OnProcessingFinished( - $command, - OnProcessingFinished::REASON_LIMIT_REACHED, - $messageCounter, - ), + $limitEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_LIMIT_REACHED, + $messageCounter, + $lastIndex, ); + $this->eventDispatcher->dispatch($limitEvent); + $errors = array_merge($errors, $limitEvent->errors); return new ProcessedResult($messageCounter, false, $errors); } } - $this->eventDispatcher->dispatch( - new OnProcessingFinished( - $command, - OnProcessingFinished::REASON_STREAM_ENDED, - $messageCounter, - ), + $finishedEvent = new OnProcessingFinished( + $command, + OnProcessingFinished::REASON_STREAM_ENDED, + $messageCounter, + $lastIndex, ); + $this->eventDispatcher->dispatch($finishedEvent); + $errors = array_merge($errors, $finishedEvent->errors); } finally { $stream?->close(); diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php index f931d850f..606b92690 100644 --- a/src/Subscription/Engine/Handler/SetupHandler.php +++ b/src/Subscription/Engine/Handler/SetupHandler.php @@ -12,10 +12,14 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriberNotFound; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionCollection; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\ConditionalRetryStrategy; +use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; +use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; use Throwable; @@ -33,6 +37,7 @@ public function __construct( private readonly MessageLoader $messageLoader, private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly RetryStrategyRepository $retryStrategyRepository, private readonly LoggerInterface|null $logger = null, ) { } @@ -130,4 +135,20 @@ function (SubscriptionCollection $subscriptions) use ($command): Result { }, ); } + + private function handleError(Subscription $subscription, Throwable $throwable): void + { + $subscriber = $this->subscriberRepository->get($subscription->id()); + $retryStrategy = $subscriber instanceof MetadataSubscriberAccessor && $subscriber->metadata()->retryStrategy !== null + ? $this->retryStrategyRepository->get($subscriber->metadata()->retryStrategy) + : $this->retryStrategyRepository->getDefaultRetryStrategy(); + + if (!$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($subscription)) { + $subscription->error($throwable); + } else { + $subscription->failed($throwable); + } + + $this->subscriptionManager->update($subscription); + } } diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php index d7a40ef93..7a0b31c49 100644 --- a/src/Subscription/Engine/Handler/TeardownHandler.php +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\Result; @@ -13,6 +14,7 @@ use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Psr\Log\LoggerInterface; +use Throwable; use function sprintf; @@ -26,6 +28,7 @@ final class TeardownHandler implements Handler public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly CleanupRunner $cleanupRunner, private readonly LoggerInterface|null $logger = null, ) { } @@ -46,7 +49,7 @@ function (SubscriptionCollection $subscriptions): Result { foreach ($subscriptions as $subscription) { if ($subscription->hasCleanupTasks()) { - $error = $this->cleanup($subscription); + $error = $this->cleanupRunner->cleanup($subscription); if ($error) { $errors[] = $error; diff --git a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php index b9d2a3793..094639811 100644 --- a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php +++ b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php @@ -48,6 +48,7 @@ public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $sk return $this->engine->run(new Setup( $criteria->ids, $criteria->groups, + $skipBooting, )); } diff --git a/src/Subscription/Engine/Listener/BatchSubscriber.php b/src/Subscription/Engine/Listener/BatchSubscriber.php index 298c55804..e71bf8819 100644 --- a/src/Subscription/Engine/Listener/BatchSubscriber.php +++ b/src/Subscription/Engine/Listener/BatchSubscriber.php @@ -8,7 +8,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageSuccess; -use Patchlevel\EventSourcing\Subscription\Engine\Event\OnResult; +use Patchlevel\EventSourcing\Subscription\Engine\Error; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnProcessingFinished; use Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -21,7 +22,7 @@ /** @internal */ class BatchSubscriber implements EventSubscriberInterface { - /** @var array */ + /** @var array */ private array $batching = []; public function __construct( @@ -55,7 +56,10 @@ public function onHandleMessage(OnHandleMessage $event): void return; } - $this->batching[$subscriberId] = $realSubscriber; + $this->batching[$subscriberId] = [ + 'subscriber' => $realSubscriber, + 'subscription' => $event->subscription, + ]; $this->logger?->debug(sprintf( 'Subscription Engine: Subscriber "%s" starts a new batch.', @@ -89,7 +93,7 @@ public function onHandleMessageSuccess(OnHandleMessageSuccess $event): void return; } - $subscriber = $this->batching[$subscriberId]; + $subscriber = $this->batching[$subscriberId]['subscriber']; unset($this->batching[$subscriberId]); $this->logger?->debug(sprintf( @@ -111,8 +115,36 @@ public function onHandleMessageSuccess(OnHandleMessageSuccess $event): void } } - public function onResult(OnResult $event): void + public function onProcessingFinished(OnProcessingFinished $event): void { + $lastIndex = $event->lastIndex; + + if ($lastIndex === null) { + return; + } + + foreach ($this->batching as $subscriberId => ['subscriber' => $subscriber, 'subscription' => $subscription]) { + unset($this->batching[$subscriberId]); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscriber "%s" commits the batch.', + $subscriberId, + )); + + try { + $subscriber->commitBatch(); + $subscription->changePosition($lastIndex); + } catch (Throwable $e) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscriber "%s" has an error in the commit batch method: %s', + $subscriberId, + $e->getMessage(), + )); + + $subscription->error($e); + $event->errors[] = new Error($subscriberId, $e->getMessage(), $e); + } + } } private function shouldCommitBatch(Subscription $subscription): bool @@ -121,7 +153,7 @@ private function shouldCommitBatch(Subscription $subscription): bool return false; } - return $this->batching[$subscription->id()]->forceCommit(); + return $this->batching[$subscription->id()]['subscriber']->forceCommit(); } public function onError(OnHandleMessageError $event): void @@ -132,7 +164,7 @@ public function onError(OnHandleMessageError $event): void return; } - $subscriber = $this->batching[$subscriptionId]; + $subscriber = $this->batching[$subscriptionId]['subscriber']; unset($this->batching[$subscriptionId]); @@ -159,6 +191,7 @@ public static function getSubscribedEvents(): array OnHandleMessage::class => 'onHandleMessage', OnHandleMessageSuccess::class => 'onHandleMessageSuccess', OnHandleMessageError::class => 'onError', + OnProcessingFinished::class => 'onProcessingFinished', ]; } } diff --git a/src/Subscription/Engine/Listener/DiscoverListener.php b/src/Subscription/Engine/Listener/DiscoverListener.php index 56f9d16d3..9384b2825 100644 --- a/src/Subscription/Engine/Listener/DiscoverListener.php +++ b/src/Subscription/Engine/Listener/DiscoverListener.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Listener; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptions; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -13,12 +14,13 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; use function array_values; use function sprintf; /** @internal */ -final class DiscoverListener +final class DiscoverListener implements EventSubscriberInterface { public function __construct( private readonly MessageLoader $messageLoader, @@ -28,16 +30,27 @@ public function __construct( ) { } - public function __invoke(OnCommand $event): void + public function onCommand(OnCommand $event): void { - $command = $event->command; + $this->discover(); + } - // todo define when to discover + public function onSubscriptions(OnSubscriptions $event): void + { + $this->discover(); + } - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( - // ids: $command->ids, - // groups: $command->groups, - )); + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => ['onCommand', 64], + OnSubscriptions::class => 'onSubscriptions', + ]; + } + + private function discover(): void + { + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria()); $latestIndex = null; diff --git a/src/Subscription/Engine/Listener/FailListener.php b/src/Subscription/Engine/Listener/FailListener.php index beff60b2b..b482ecaf1 100644 --- a/src/Subscription/Engine/Listener/FailListener.php +++ b/src/Subscription/Engine/Listener/FailListener.php @@ -5,17 +5,19 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Listener; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; use Throwable; use function sprintf; /** @internal */ -class FailListener +class FailListener implements EventSubscriberInterface { public function __construct( private readonly SubscriptionManager $subscriptionManager, @@ -37,7 +39,7 @@ public function handleFailed( return; } - $subscriber = $this->subscriber($subscription->id()); + $subscriber = $this->subscriberRepository->get($subscription->id()); if (!$subscriber) { $subscription->failed($throwable); @@ -79,4 +81,20 @@ public function handleFailed( $this->subscriptionManager->update($subscription); } } + + public function onHandleMessageError(OnHandleMessageError $event): void + { + if (!$event->transitionToFailed) { + return; + } + + $this->handleFailed($event->subscription, $event->throwable, $event->message, $event->index); + } + + public static function getSubscribedEvents(): array + { + return [ + OnHandleMessageError::class => ['onHandleMessageError', -8], + ]; + } } diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index 6ef42739b..01ba3d4ea 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -37,10 +37,6 @@ public function process(int $index, Message $message, Subscription $subscription $subscribeMethods = $subscriber->subscribeMethods($message->event()::class); if ($subscribeMethods === []) { - if (!isset($this->batching[$subscription->id()])) { - $subscription->changePosition($index); - } - $this->logger?->debug( sprintf( 'Subscription Engine: Subscriber "%s" for "%s" has no subscribe methods for "%s", continue.', @@ -50,6 +46,13 @@ public function process(int $index, Message $message, Subscription $subscription ), ); + $event = new OnHandleMessageSuccess($subscription, $message, $index); + $this->eventDispatcher->dispatch($event); + + if ($event->shouldChangePosition) { + $subscription->changePosition($index); + } + return null; } @@ -61,6 +64,15 @@ public function process(int $index, Message $message, Subscription $subscription $this->eventDispatcher->dispatch($event); } catch (Throwable $e) { + $this->eventDispatcher->dispatch( + new OnHandleMessageError( + $subscription, + $e, + $message, + $index, + ), + ); + return new Error( $subscription->id(), $e->getMessage(), diff --git a/src/Subscription/Engine/NextSubscriptionEngine.php b/src/Subscription/Engine/NextSubscriptionEngine.php index ba08b86e5..211d882a1 100644 --- a/src/Subscription/Engine/NextSubscriptionEngine.php +++ b/src/Subscription/Engine/NextSubscriptionEngine.php @@ -6,6 +6,7 @@ use InvalidArgumentException; use Patchlevel\EventSourcing\Subscription\Cleanup\Cleaner; +use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; @@ -17,6 +18,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnResult; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptions; use Patchlevel\EventSourcing\Subscription\Engine\Handler\BootHandler; use Patchlevel\EventSourcing\Subscription\Engine\Handler\Handler; use Patchlevel\EventSourcing\Subscription\Engine\Handler\PauseHandler; @@ -29,6 +31,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Listener\BatchSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Listener\DetachListener; use Patchlevel\EventSourcing\Subscription\Engine\Listener\DiscoverListener; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailListener; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; @@ -72,6 +75,12 @@ public function __construct( ]); } + $cleanupRunner = new CleanupRunner( + $this->subscriptionManager, + $this->cleaner, + $this->logger, + ); + $messageProcessor = new MessageProcessor( $this->subscriberRepository, $this->eventDispatcher, @@ -104,7 +113,7 @@ public function __construct( Remove::class => new RemoveHandler( $this->subscriptionManager, $this->subscriberRepository, - $this->cleaner, + $cleanupRunner, $this->logger, ), Run::class => new RunHandler( @@ -119,24 +128,24 @@ public function __construct( $this->messageLoader, $this->subscriptionManager, $this->subscriberRepository, + $this->retryStrategyRepository, $this->logger, ), Teardown::class => new TeardownHandler( $this->subscriptionManager, $this->subscriberRepository, + $cleanupRunner, $this->logger, ), ]; - $this->eventDispatcher->addListener( - OnCommand::class, + $this->eventDispatcher->addSubscriber( new DiscoverListener( $this->messageLoader, $this->subscriptionManager, $this->subscriberRepository, $this->logger, ), - 64, ); $this->eventDispatcher->addSubscriber( @@ -155,6 +164,14 @@ public function __construct( ), ); + $this->eventDispatcher->addSubscriber( + new FailListener( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + ); + $this->eventDispatcher->addListener( OnCommand::class, new DetachListener( @@ -200,7 +217,7 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): { $criteria ??= new SubscriptionEngineCriteria(); - // todo dispatch event for discover + $this->eventDispatcher->dispatch(new OnSubscriptions($criteria)); return $this->subscriptionManager->find( new SubscriptionCriteria( From e5bfe33ad45b03b39a815b032a94ca23fcd38516 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 22 May 2026 09:07:25 +0200 Subject: [PATCH 03/12] some fixes --- src/Subscription/Engine/Handler/BootHandler.php | 2 +- src/Subscription/Engine/Handler/RunHandler.php | 3 ++- .../Engine/Handler/TeardownHandler.php | 3 ++- .../Engine/Listener/BatchSubscriber.php | 9 +++------ ...scoverListener.php => DiscoverSubscriber.php} | 3 ++- .../{FailListener.php => FailSubscriber.php} | 3 ++- .../Engine/Listener/RetrySubscriber.php | 1 + src/Subscription/Engine/MessageProcessor.php | 16 +++++++++++++--- .../Engine/NextSubscriptionEngine.php | 9 ++++----- 9 files changed, 30 insertions(+), 19 deletions(-) rename src/Subscription/Engine/Listener/{DiscoverListener.php => DiscoverSubscriber.php} (96%) rename src/Subscription/Engine/Listener/{FailListener.php => FailSubscriber.php} (95%) diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 56ae0901f..d0d9fd0a8 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -12,7 +12,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionCollection; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; @@ -21,6 +20,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use function array_merge; use function count; use function sprintf; diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index 3441d34ea..686d83b2e 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -20,6 +20,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use function array_merge; use function count; use function sprintf; @@ -103,7 +104,7 @@ function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult if (count($subscriptions) === 0) { $this->logger?->info( - 'Subscription Engine: No subscriptions in booting status, finish booting.', + 'Subscription Engine: No subscriptions in active status, finish processing.', ); break 2; diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php index 7a0b31c49..82b7e0bb0 100644 --- a/src/Subscription/Engine/Handler/TeardownHandler.php +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionCollection; @@ -21,7 +22,7 @@ /** * @internal * - * @implements Handler + * @implements Handler */ final class TeardownHandler implements Handler { diff --git a/src/Subscription/Engine/Listener/BatchSubscriber.php b/src/Subscription/Engine/Listener/BatchSubscriber.php index e71bf8819..1f06d0fe3 100644 --- a/src/Subscription/Engine/Listener/BatchSubscriber.php +++ b/src/Subscription/Engine/Listener/BatchSubscriber.php @@ -4,11 +4,11 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Listener; +use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageSuccess; -use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnProcessingFinished; use Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; @@ -20,7 +20,7 @@ use function sprintf; /** @internal */ -class BatchSubscriber implements EventSubscriberInterface +final class BatchSubscriber implements EventSubscriberInterface { /** @var array */ private array $batching = []; @@ -149,10 +149,6 @@ public function onProcessingFinished(OnProcessingFinished $event): void private function shouldCommitBatch(Subscription $subscription): bool { - if (!isset($this->batching[$subscription->id()])) { - return false; - } - return $this->batching[$subscription->id()]['subscriber']->forceCommit(); } @@ -184,6 +180,7 @@ public function onError(OnHandleMessageError $event): void } } + /** @return array */ public static function getSubscribedEvents(): array { return [ diff --git a/src/Subscription/Engine/Listener/DiscoverListener.php b/src/Subscription/Engine/Listener/DiscoverSubscriber.php similarity index 96% rename from src/Subscription/Engine/Listener/DiscoverListener.php rename to src/Subscription/Engine/Listener/DiscoverSubscriber.php index 9384b2825..c02cfd788 100644 --- a/src/Subscription/Engine/Listener/DiscoverListener.php +++ b/src/Subscription/Engine/Listener/DiscoverSubscriber.php @@ -20,7 +20,7 @@ use function sprintf; /** @internal */ -final class DiscoverListener implements EventSubscriberInterface +final class DiscoverSubscriber implements EventSubscriberInterface { public function __construct( private readonly MessageLoader $messageLoader, @@ -40,6 +40,7 @@ public function onSubscriptions(OnSubscriptions $event): void $this->discover(); } + /** @return array */ public static function getSubscribedEvents(): array { return [ diff --git a/src/Subscription/Engine/Listener/FailListener.php b/src/Subscription/Engine/Listener/FailSubscriber.php similarity index 95% rename from src/Subscription/Engine/Listener/FailListener.php rename to src/Subscription/Engine/Listener/FailSubscriber.php index b482ecaf1..48c7b63f1 100644 --- a/src/Subscription/Engine/Listener/FailListener.php +++ b/src/Subscription/Engine/Listener/FailSubscriber.php @@ -17,7 +17,7 @@ use function sprintf; /** @internal */ -class FailListener implements EventSubscriberInterface +final class FailSubscriber implements EventSubscriberInterface { public function __construct( private readonly SubscriptionManager $subscriptionManager, @@ -91,6 +91,7 @@ public function onHandleMessageError(OnHandleMessageError $event): void $this->handleFailed($event->subscription, $event->throwable, $event->message, $event->index); } + /** @return array */ public static function getSubscribedEvents(): array { return [ diff --git a/src/Subscription/Engine/Listener/RetrySubscriber.php b/src/Subscription/Engine/Listener/RetrySubscriber.php index 4c0c7400b..4a7758912 100644 --- a/src/Subscription/Engine/Listener/RetrySubscriber.php +++ b/src/Subscription/Engine/Listener/RetrySubscriber.php @@ -126,6 +126,7 @@ private function retryStrategy(Subscription $subscription): RetryStrategy return $this->retryStrategyRepository->get($retryStrategy); } + /** @return array */ public static function getSubscribedEvents(): array { return [ diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index 01ba3d4ea..534f76a0a 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -11,7 +11,7 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; -use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Throwable; use function sprintf; @@ -21,7 +21,7 @@ final class MessageProcessor { public function __construct( private readonly SubscriberAccessorRepository $subscriberRepository, - private readonly EventDispatcher $eventDispatcher, + private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggerInterface|null $logger = null, ) { } @@ -57,13 +57,23 @@ public function process(int $index, Message $message, Subscription $subscription } try { - $event = new OnHandleMessage( + $event = new OnHandleMessage( $subscription, $message, ); $this->eventDispatcher->dispatch($event); } catch (Throwable $e) { + $this->logger?->error( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', + $subscriber::class, + $subscription->id(), + $message->event()::class, + $e->getMessage(), + ), + ); + $this->eventDispatcher->dispatch( new OnHandleMessageError( $subscription, diff --git a/src/Subscription/Engine/NextSubscriptionEngine.php b/src/Subscription/Engine/NextSubscriptionEngine.php index 211d882a1..e9be66883 100644 --- a/src/Subscription/Engine/NextSubscriptionEngine.php +++ b/src/Subscription/Engine/NextSubscriptionEngine.php @@ -6,7 +6,6 @@ use InvalidArgumentException; use Patchlevel\EventSourcing\Subscription\Cleanup\Cleaner; -use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; @@ -30,8 +29,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\Handler\TeardownHandler; use Patchlevel\EventSourcing\Subscription\Engine\Listener\BatchSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Listener\DetachListener; -use Patchlevel\EventSourcing\Subscription\Engine\Listener\DiscoverListener; -use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailListener; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\DiscoverSubscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; @@ -140,7 +139,7 @@ public function __construct( ]; $this->eventDispatcher->addSubscriber( - new DiscoverListener( + new DiscoverSubscriber( $this->messageLoader, $this->subscriptionManager, $this->subscriberRepository, @@ -165,7 +164,7 @@ public function __construct( ); $this->eventDispatcher->addSubscriber( - new FailListener( + new FailSubscriber( $this->subscriptionManager, $this->subscriberRepository, $this->logger, From f17c3f51a706eb36e37fe81fd2e0e25108f586d1 Mon Sep 17 00:00:00 2001 From: David Badura Date: Fri, 22 May 2026 09:16:26 +0200 Subject: [PATCH 04/12] fix phpstan --- phpstan-baseline.neon | 6 ++++++ src/Subscription/Engine/Handler/RunHandler.php | 2 -- .../Engine/LegacyWrapperSubscriptionEngine.php | 14 ++++++++++++-- src/Subscription/Engine/NextSubscriptionEngine.php | 1 - tests/Architecture/FinalClassesTest.php | 2 ++ 5 files changed, 20 insertions(+), 5 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a4a436217..1c4cd5287 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -102,6 +102,12 @@ parameters: count: 1 path: src/Store/TaggableDoctrineDbalStore.php + - + message: '#^Parameter \#1 \$command of callable Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\BootHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\PauseHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\ReactivateHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RefreshHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RemoveHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RunHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\SetupHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\TeardownHandler expects Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Boot\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Pause\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Reactivate\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Refresh\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Remove\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Run\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Setup\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Teardown, Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Command given\.$#' + identifier: argument.type + count: 1 + path: src/Subscription/Engine/NextSubscriptionEngine.php + - message: '#^Parameter \#1 \$eventClass of method Patchlevel\\EventSourcing\\Metadata\\Event\\EventRegistry\:\:eventName\(\) expects class\-string, string given\.$#' identifier: argument.type diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index 686d83b2e..8e8f8318f 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -16,7 +16,6 @@ use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; -use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; @@ -34,7 +33,6 @@ final class RunHandler implements Handler public function __construct( private readonly MessageLoader $messageLoader, private readonly SubscriptionManager $subscriptionManager, - private readonly SubscriberAccessorRepository $subscriberRepository, private readonly MessageProcessor $messageProcessor, private readonly EventDispatcherInterface $eventDispatcher, private readonly LoggerInterface|null $logger = null, diff --git a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php index 094639811..5ddffa878 100644 --- a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php +++ b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php @@ -19,6 +19,8 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; +use function assert; + final class LegacyWrapperSubscriptionEngine implements SubscriptionEngine, CanRefreshSubscriptions { private readonly NextSubscriptionEngine $engine; @@ -58,11 +60,15 @@ public function boot( ): ProcessedResult { $criteria ??= new SubscriptionEngineCriteria(); - return $this->engine->run(new Boot( + $result = $this->engine->run(new Boot( $criteria->ids, $criteria->groups, $limit, )); + + assert($result instanceof ProcessedResult); + + return $result; } public function run( @@ -71,11 +77,15 @@ public function run( ): ProcessedResult { $criteria ??= new SubscriptionEngineCriteria(); - return $this->engine->run(new Run( + $result = $this->engine->run(new Run( $criteria->ids, $criteria->groups, $limit, )); + + assert($result instanceof ProcessedResult); + + return $result; } public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result diff --git a/src/Subscription/Engine/NextSubscriptionEngine.php b/src/Subscription/Engine/NextSubscriptionEngine.php index e9be66883..87fc9907b 100644 --- a/src/Subscription/Engine/NextSubscriptionEngine.php +++ b/src/Subscription/Engine/NextSubscriptionEngine.php @@ -118,7 +118,6 @@ public function __construct( Run::class => new RunHandler( $this->messageLoader, $this->subscriptionManager, - $this->subscriberRepository, $messageProcessor, $this->eventDispatcher, $this->logger, diff --git a/tests/Architecture/FinalClassesTest.php b/tests/Architecture/FinalClassesTest.php index e9b4c65d9..b03094b5f 100644 --- a/tests/Architecture/FinalClassesTest.php +++ b/tests/Architecture/FinalClassesTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Architecture; use Patchlevel\EventSourcing\Attribute\Subscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Result; use PHPat\Selector\Selector; use PHPat\Test\Builder\Rule; use PHPat\Test\PHPat; @@ -20,6 +21,7 @@ public function testFinalClasses(): Rule Selector::NOT(Selector::isAbstract()), Selector::NOT(Selector::isInterface()), Selector::NOT(Selector::classname(Subscriber::class)), + Selector::NOT(Selector::classname(Result::class)), ), ) ->shouldBeFinal(); From 00ecba968c90aba8aa774d2852f6acd6541fc415 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 9 Jun 2026 10:41:02 +0200 Subject: [PATCH 05/12] improve logs --- src/Subscription/Engine/Handler/BootHandler.php | 6 ------ src/Subscription/Engine/Handler/SetupHandler.php | 4 ---- .../Engine/Handler/TeardownHandler.php | 4 ---- .../Engine/NextSubscriptionEngine.php | 16 ++++++++++++++++ 4 files changed, 16 insertions(+), 14 deletions(-) diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index d0d9fd0a8..627179b91 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -43,10 +43,6 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { - $this->logger?->info( - 'Subscription Engine: Start booting.', - ); - return $this->subscriptionManager->findForUpdate( new SubscriptionCriteria( ids: $command->ids, @@ -210,8 +206,6 @@ function (SubscriptionCollection $subscriptions) use ($command): ProcessedResult )); } - $this->logger?->info('Subscription Engine: Finish booting.'); - return new ProcessedResult( $messageCounter, true, diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php index 606b92690..00c3e3559 100644 --- a/src/Subscription/Engine/Handler/SetupHandler.php +++ b/src/Subscription/Engine/Handler/SetupHandler.php @@ -44,10 +44,6 @@ public function __construct( public function __invoke(Command $command): Result { - $this->logger?->info( - 'Subscription Engine: Start to setup.', - ); - return $this->subscriptionManager->findForUpdate( new SubscriptionCriteria( ids: $command->ids, diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php index 82b7e0bb0..51a14c593 100644 --- a/src/Subscription/Engine/Handler/TeardownHandler.php +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -36,8 +36,6 @@ public function __construct( public function __invoke(Command $command): Result { - $this->logger?->info('Subscription Engine: Start teardown detached subscriptions.'); - return $this->subscriptionManager->findForUpdate( new SubscriptionCriteria( ids: $command->ids, @@ -125,8 +123,6 @@ function (SubscriptionCollection $subscriptions): Result { ); } - $this->logger?->info('Subscription Engine: Finish teardown.'); - return new Result($errors); }, ); diff --git a/src/Subscription/Engine/NextSubscriptionEngine.php b/src/Subscription/Engine/NextSubscriptionEngine.php index 87fc9907b..86a337cd8 100644 --- a/src/Subscription/Engine/NextSubscriptionEngine.php +++ b/src/Subscription/Engine/NextSubscriptionEngine.php @@ -183,7 +183,15 @@ public function __construct( public function run(Command $command): Result { + $this->logger?->info( + 'Subscription Engine: ' . $command::class . ' command received.', + ); + if ($this->processing) { + $this->logger?->error( + 'Subscription Engine: Already processing, skip.', + ); + throw new AlreadyProcessing(); } @@ -196,6 +204,10 @@ public function run(Command $command): Result throw new InvalidArgumentException('No handler found for command: ' . $command::class); } + $this->logger?->debug( + 'Subscription Engine: ' . $command::class . ' command handled by ' . $handler::class, + ); + $event = new OnCommand($command); $this->eventDispatcher->dispatch($event); @@ -206,6 +218,10 @@ public function run(Command $command): Result return $result; } finally { + $this->logger?->info( + 'Subscription Engine: ' . $command::class . ' command processed.', + ); + $this->processing = false; } } From 8702847f4df59da3648423f09a7d46356b0a8d92 Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 9 Jun 2026 18:59:23 +0200 Subject: [PATCH 06/12] split tests --- .../Engine/Handler/BootHandlerTest.php | 591 +++++++++++++++ .../Engine/Handler/PauseHandlerTest.php | 95 +++ .../Engine/Handler/ReactivateHandlerTest.php | 123 ++++ .../Engine/Handler/RefreshHandlerTest.php | 128 ++++ .../Engine/Handler/RemoveHandlerTest.php | 254 +++++++ .../Engine/Handler/RunHandlerTest.php | 540 ++++++++++++++ .../Engine/Handler/SetupHandlerTest.php | 353 +++++++++ .../Engine/Handler/TeardownHandlerTest.php | 262 +++++++ .../Engine/Listener/BatchSubscriberTest.php | 694 ++++++++++++++++++ .../Engine/Listener/DetachListenerTest.php | 87 +++ .../Listener/DiscoverSubscriberTest.php | 228 ++++++ .../Engine/Listener/FailSubscriberTest.php | 271 +++++++ .../Engine/Listener/RetrySubscriberTest.php | 150 ++++ .../Engine/NextSubscriptionEngineTest.php | 109 +++ 14 files changed, 3885 insertions(+) create mode 100644 tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php create mode 100644 tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php create mode 100644 tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php create mode 100644 tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php create mode 100644 tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php create mode 100644 tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php create mode 100644 tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php diff --git a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php new file mode 100644 index 000000000..aeca0f4a0 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php @@ -0,0 +1,591 @@ + new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + return new BootHandler($messageLoader, $subscriptionManager, $subscriberRepository, $messageProcessor, $eventDispatcher, new NullLogger()); + } + + public function testNothingToBoot(): void + { + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + $store = new DummySubscriptionStore(); + $handler = $this->createHandler($messageLoader, $store); + + $result = $handler(new BootCommand()); + + self::assertEquals(0, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoChanges(); + } + + public function testBootWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber->message); + } + + public function testBootWithError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithErrorNoRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithErrorAndRecovery(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting, 1), + ); + } + + public function testBootWithErrorAndRecoveryFailed(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + throw new RuntimeException('RECOVERY ERROR'); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithErrorAndRecoveryFailedBecauseBatching(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class implements BatchableSubscriber { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + + public function beginBatch(): void + { + } + + public function commitBatch(): void + { + } + + public function rollbackBatch(): void + { + } + + public function forceCommit(): bool + { + return false; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testBootWithLimit(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand(limit: 1)); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(false, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting, 1), + ); + + self::assertSame($message, $subscriber->message); + } + + public function testBootingWithSkip(): void + { + $subscriptionId1 = 'test1'; + $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionId2 = 'test2'; + $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting, 1), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber1, $subscriber2]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber1->message); + self::assertNull($subscriber2->message); + } + + public function testBootingWithGabInIndex(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + /** @var list */ + public array $messages = []; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->messages[] = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 3 => $message2, + ])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 3), + ); + + self::assertSame([$message1, $message2], $subscriber->messages); + } + + public function testBootingWithOnlyOnce(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::Once)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Finished, 1), + ); + + self::assertEquals($message, $subscriber->message); + } + + public function testBootWithoutSubscriber(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->willReturn(new Stream([1 => $message])); + + $handler = $this->createHandler($messageLoader, $store); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoChanges(); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php new file mode 100644 index 000000000..b8c4fae98 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php @@ -0,0 +1,95 @@ +createHandler($store); + $result = $handler(new PauseCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused), + ); + } + + public function testPauseActive(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new PauseCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused), + ); + } + + public function testPauseError(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new PauseCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Paused, + 0, + new SubscriptionError('ERROR', Status::New), + ), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php new file mode 100644 index 000000000..ffac45118 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php @@ -0,0 +1,123 @@ +createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New, 0), + ); + } + + public function testReactivateDetached(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } + + public function testReactivatePaused(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } + + public function testReactivateFinished(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Finished), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new ReactivateCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php new file mode 100644 index 000000000..d100e55bb --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php @@ -0,0 +1,128 @@ +createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertNoChanges(); + } + + public function testRefreshSubscriptionsChangeRunMode(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromNow)] + class { + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'default', RunMode::FromNow, Status::Active), + ); + } + + public function testRefreshSubscriptionsChangeGroup(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'new-group')] + class { + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'new-group', RunMode::FromBeginning, Status::Active), + ); + } + + public function testRefreshSubscriptionsChangeCleanupTasks(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + /** @return iterable */ + #[Cleanup] + public function cleanup(): iterable + { + yield new DropTableTask('test'); + } + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'default', RunMode::FromBeginning, Status::Active, cleanupTasks: [new DropTableTask('test')]), + ); + } + + public function testRefreshSubscriptionsMultipleChanges(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromNow, group: 'new-group')] + class { + /** @return iterable */ + #[Cleanup] + public function cleanup(): iterable + { + yield new DropTableTask('test'); + } + }; + + $subscription = new Subscription('test', 'default', RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $handler(new RefreshCommand()); + + $store->assertUpdated( + new Subscription('test', 'new-group', RunMode::FromNow, Status::Active, cleanupTasks: [new DropTableTask('test')]), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php new file mode 100644 index 000000000..1d174a1e8 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php @@ -0,0 +1,254 @@ +dropped = true; + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + self::assertTrue($subscriber->dropped); + } + + public function testRemoveWithoutDropMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveWithSubscriberAndError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + #[Teardown] + public function drop(): void + { + throw new RuntimeException('ERROR'); + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveNewSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + $this->dropped = true; + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + self::assertFalse($subscriber->dropped); + } + + public function testRemoveWithoutSubscriber(): void + { + $subscriberId = 'test'; + + $subscription = new Subscription($subscriberId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store); + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveWithCleanupAndWithoutCleaner(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [new DropTableTask('test')], + ); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + + $this->expectException(CleanerNotConfigured::class); + $handler(new RemoveCommand()); + } + + public function testRemoveWithCleanupAndSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new RemoveHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([$subscriber]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new RemoveCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testRemoveWithCleanupHandlerError(): void + { + $subscriptionId = 'test'; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new RemoveHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new RemoveCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertInstanceOf(CleanupFailed::class, $error->throwable); + + $store->assertRemoved($subscription); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php new file mode 100644 index 000000000..0ef11ed0c --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php @@ -0,0 +1,540 @@ + new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addListener(OnCommand::class, new DetachListener($subscriptionManager, $subscriberRepository, new NullLogger()), 32); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + $handler = new RunHandler($messageLoader, $subscriptionManager, $messageProcessor, $eventDispatcher, new NullLogger()); + + return [$handler, $eventDispatcher, new RunCommand()]; + } + + public function testRunning(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber->message); + } + + public function testRunningWithLimit(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null) + ->willReturn(new Stream([1 => $message1, 2 => $message2])); + + $command = new RunCommand(limit: 1); + [$handler, $eventDispatcher] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(false, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message1, $subscriber->message); + } + + public function testRunningWithSkip(): void + { + $subscriptionId1 = 'test1'; + $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $subscriptionId2 = 'test2'; + $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber1, $subscriber2]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId1, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + new Subscription($subscriptionId2, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + + self::assertSame($message, $subscriber1->message); + self::assertNull($subscriber2->message); + } + + public function testRunningWithError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testRunningWithErrorNoRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testRunningWithErrorAndRecovery(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + } + + public function testRunningWithErrorAndRecoveryFailed(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + throw new RuntimeException('RECOVERY ERROR'); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($subscriber->exception)), + ), + ); + } + + public function testRunningMarkDetached(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + // DetachListener fires on OnCommand and marks the subscription as Detached + // before the handler processes it (no subscriber registered) + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(0, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached, 0), + ); + } + + public function testRunningWithoutActiveSubscribers(): void + { + $subscriptionId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(0, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoChanges(); + } + + public function testRunningWithGabInIndex(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + /** @var list */ + public array $messages = []; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->messages[] = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 3 => $message2, + ])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 3), + ); + + self::assertSame([$message1, $message2], $subscriber->messages); + } + + public function testRunningWithOnlyOnce(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::Once)] + class { + public Message|null $message = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(Message $message): void + { + $this->message = $message; + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Active), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::Once, Status::Finished, 1), + ); + + self::assertEquals($message, $subscriber->message); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php new file mode 100644 index 000000000..1ee4d9901 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php @@ -0,0 +1,353 @@ + new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]), + new NullLogger(), + ); + } + + public function testNothingToSetup(): void + { + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->never())->method('load'); + + $store = new DummySubscriptionStore(); + $handler = $this->createHandler($messageLoader, $store); + + $result = $handler(new SetupCommand()); + + $store->assertNoChanges(); + self::assertEquals([], $result->errors); + } + + public function testSetupWithoutCreateMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ); + } + + public function testSetupWithCreateMethod(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $created = false; + + #[Setup] + public function create(): void + { + $this->created = true; + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ); + + self::assertTrue($subscriber->created); + } + + public function testSetupWithCreateError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Setup] + public function create(): void + { + throw $this->exception; + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ); + } + + public function testSetupWithCreateErrorNoRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Setup] + public function create(): void + { + throw $this->exception; + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ); + } + + public function testSetupWithCreateErrorRecoveryNotPossible(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + public function __construct( + public readonly RuntimeException $exception = new RuntimeException('ERROR'), + ) { + } + + #[Setup] + public function create(): void + { + throw $this->exception; + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + 0, + new SubscriptionError( + 'ERROR', + Status::New, + ThrowableToErrorContextTransformer::transform($subscriber->exception), + ), + ), + ); + } + + public function testSetupWithSkipBooting(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand(skipBooting: true)); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ); + } + + public function testSetupWithFromNow(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromNow)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(1); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::Active, 1), + ); + } + + public function testSetupWithFromNowWithEmptyStream(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromNow)] + class { + }; + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('lastIndex')->willReturn(0); + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::New), + ]); + + $handler = $this->createHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new SetupCommand()); + + self::assertEquals([], $result->errors); + + $store->assertUpdated( + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromNow, Status::Active, 0), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php new file mode 100644 index 000000000..1558fbc99 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php @@ -0,0 +1,262 @@ +createHandler($store, [$subscriber]); + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testTeardownWithSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $dropped = false; + + #[Teardown] + public function drop(): void + { + $this->dropped = true; + } + }; + + $subscription = new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + self::assertTrue($subscriber->dropped); + } + + public function testTeardownWithSubscriberAndError(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + #[Teardown] + public function drop(): void + { + throw new RuntimeException('ERROR'); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ]); + + $handler = $this->createHandler($store, [$subscriber]); + $result = $handler(new TeardownCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(RuntimeException::class, $error->throwable); + + $store->assertNoChanges(); + } + + public function testTeardownWithoutSubscriber(): void + { + $subscriberId = 'test'; + + $store = new DummySubscriptionStore([ + new Subscription($subscriberId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ]); + + $handler = $this->createHandler($store); + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoChanges(); + } + + public function testTeardownWithCleanupAndWithoutCleaner(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [new DropTableTask('test')], + ); + $store = new DummySubscriptionStore([$subscription]); + + $handler = $this->createHandler($store, [$subscriber]); + + $this->expectException(CleanerNotConfigured::class); + $handler(new TeardownCommand()); + } + + public function testTeardownWithCleanupAndSubscriber(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([$subscriber]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testTeardownWithCleanupAndWithoutSubscriber(): void + { + $subscriptionId = 'test'; + + $task = new DropTableTask('test'); + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ); + $store = new DummySubscriptionStore([$subscription]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new TeardownCommand()); + + self::assertEquals([], $result->errors); + $store->assertNoUpdated(); + $store->assertRemoved($subscription); + } + + public function testTeardownWithCleanupHandlerError(): void + { + $subscriptionId = 'test'; + + $task = new DropTableTask('test'); + $store = new DummySubscriptionStore([ + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Detached, + cleanupTasks: [$task], + ), + ]); + + $cleanupHandler = $this->createMock(CleanupTaskHandler::class); + $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); + $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); + + $subscriptionManager = new SubscriptionManager($store); + $handler = new TeardownHandler( + $subscriptionManager, + new MetadataSubscriberAccessorRepository([]), + new CleanupRunner($subscriptionManager, new DefaultCleaner([$cleanupHandler]), new NullLogger()), + new NullLogger(), + ); + + $result = $handler(new TeardownCommand()); + + self::assertCount(1, $result->errors); + + $error = $result->errors[0]; + self::assertEquals($subscriptionId, $error->subscriptionId); + self::assertInstanceOf(CleanupFailed::class, $error->throwable); + + $store->assertNoChanges(); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php new file mode 100644 index 000000000..bcfba963a --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php @@ -0,0 +1,694 @@ + new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new BatchSubscriber($subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + return new BootHandler($messageLoader, $subscriptionManager, $subscriberRepository, $messageProcessor, $eventDispatcher, new NullLogger()); + } + + private function createRunHandler( + MessageLoader $messageLoader, + DummySubscriptionStore $store, + array $subscribers, + ): array { + $retryStrategyRepository = new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), + 'no_retry' => new NoRetryStrategy(), + ]); + + $subscriberRepository = new MetadataSubscriberAccessorRepository($subscribers); + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + + $eventDispatcher->addSubscriber(new BatchSubscriber($subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addListener(OnCommand::class, new DetachListener($subscriptionManager, $subscriberRepository, new NullLogger()), 32); + + $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, new NullLogger()); + + $handler = new RunHandler($messageLoader, $subscriptionManager, $messageProcessor, $eventDispatcher, new NullLogger()); + + return [$handler, $eventDispatcher, new RunCommand()]; + } + + public function testBootBatchingSuccess(): void + { + $subscriber = new BatchingSubscriber(); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingSuccessForceCommit(): void + { + $subscriber = new BatchingSubscriber( + forceCommitAfterMessages: 1, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 2 => $message2, + ])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 2, + ), + ); + + self::assertSame([$message1, $message2], $subscriber->receivedMessages); + self::assertSame(2, $subscriber->beginBatchCalled); + self::assertSame(2, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithHandleError(): void + { + $subscriber = new BatchingSubscriber( + throwForMessage: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithBeginBatchError(): void + { + $subscriber = new BatchingSubscriber( + throwForBeginBatch: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), + ), + ), + ); + + self::assertSame([], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithCommitBatchError(): void + { + $subscriber = new BatchingSubscriber( + throwForCommitBatch: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testBootBatchingWithRollbackBatchError(): void + { + $subscriber = new BatchingSubscriber( + throwForMessage: new \RuntimeException('ERROR'), + throwForRollbackBatch: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Booting, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + $handler = $this->createBootHandler($messageLoader, $store, [$subscriber]); + $result = $handler(new BootCommand()); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Booting, + ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingSuccess(): void + { + $subscriber = new BatchingSubscriber(); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 1, + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingSuccessForceCommit(): void + { + $subscriber = new BatchingSubscriber( + forceCommitAfterMessages: 1, + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([ + 1 => $message1, + 2 => $message2, + ])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(2, $result->processedMessages); + self::assertEquals(true, $result->finished); + self::assertEquals([], $result->errors); + + $store->assertNoAdded(); + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + 2, + ), + ); + + self::assertSame([$message1, $message2], $subscriber->receivedMessages); + self::assertSame(2, $subscriber->beginBatchCalled); + self::assertSame(2, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithHandleError(): void + { + $subscriber = new BatchingSubscriber( + throwForMessage: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithBeginBatchError(): void + { + $subscriber = new BatchingSubscriber( + throwForBeginBatch: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), + ), + ), + ); + + self::assertSame([], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithCommitBatchError(): void + { + $subscriber = new BatchingSubscriber( + throwForCommitBatch: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(1, $subscriber->commitBatchCalled); + self::assertSame(0, $subscriber->rollbackBatchCalled); + } + + public function testRunningBatchingWithRollbackBatchError(): void + { + $subscriber = new BatchingSubscriber( + throwForMessage: new \RuntimeException('ERROR'), + throwForRollbackBatch: new \RuntimeException('ERROR'), + ); + + $store = new DummySubscriptionStore([ + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Active, + ), + ]); + + $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); + + [$handler, $eventDispatcher, $command] = $this->createRunHandler($messageLoader, $store, [$subscriber]); + $eventDispatcher->dispatch(new OnCommand($command)); + $result = $handler($command); + + self::assertEquals(1, $result->processedMessages); + self::assertEquals(true, $result->finished); + + $error = $result->errors[0]; + self::assertEquals($subscriber::ID, $error->subscriptionId); + self::assertEquals('ERROR', $error->message); + self::assertInstanceOf(\RuntimeException::class, $error->throwable); + + $store->assertUpdated( + new Subscription( + $subscriber::ID, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + null, + new SubscriptionError( + 'ERROR', + Status::Active, + ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ), + ), + ); + + self::assertSame([$message], $subscriber->receivedMessages); + self::assertSame(1, $subscriber->beginBatchCalled); + self::assertSame(0, $subscriber->commitBatchCalled); + self::assertSame(1, $subscriber->rollbackBatchCalled); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php new file mode 100644 index 000000000..3252f38eb --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php @@ -0,0 +1,87 @@ +createListener($store); + $listener(new OnCommand(new Run())); + + $store->assertUpdated( + new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ); + } + + public function testDoesNotDetachWhenSubscriberExists(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $listener = $this->createListener($store, [$subscriber]); + $listener(new OnCommand(new Run())); + + $store->assertNoChanges(); + } + + public function testIgnoresNonRunCommands(): void + { + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + $listener = $this->createListener($store); + $listener(new OnCommand(new Boot())); + + $store->assertNoChanges(); + } + + public function testDetachesPausedAndFinishedSubscriptions(): void + { + $paused = new Subscription('paused', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Paused); + $finished = new Subscription('finished', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Finished); + $store = new DummySubscriptionStore([$paused, $finished]); + + $listener = $this->createListener($store); + $listener(new OnCommand(new Run())); + + $store->assertUpdated( + new Subscription('paused', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + new Subscription('finished', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php new file mode 100644 index 000000000..a09d1c602 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php @@ -0,0 +1,228 @@ +createMock(MessageLoader::class), + new SubscriptionManager($store), + new MetadataSubscriberAccessorRepository($subscribers), + new NullLogger(), + ); + } + + public function testBootDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Boot())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testRunDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Run())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testTeardownDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Teardown())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testRemoveDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Remove())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testReactiveDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Reactivate())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testPauseDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onCommand(new OnCommand(new Pause())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testGetSubscriptionAndDiscoverNewSubscribers(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $store = new DummySubscriptionStore(); + $listener = $this->createListener($store, [$subscriber]); + + $listener->onSubscriptions(new OnSubscriptions(new SubscriptionEngineCriteria())); + + $store->assertAdded( + new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::New, + ), + ); + } + + public function testDontLockGetSubscriptions(): void + { + $subscriber = new #[Subscriber('id1', RunMode::FromNow)] + class { + }; + + $subscriptionStore = $this->createMock(LockableSubscriptionStore::class); + $subscriptionStore + ->expects($this->never()) + ->method('inLock'); + + $subscriptionStore + ->expects($this->once()) + ->method('find') + ->with(new SubscriptionCriteria()) + ->willReturn([new Subscription('id1')]); + + $subscriptionStore + ->expects($this->never()) + ->method('add'); + + $listener = new DiscoverSubscriber( + $this->createMock(MessageLoader::class), + new SubscriptionManager($subscriptionStore), + new MetadataSubscriberAccessorRepository([$subscriber]), + new NullLogger(), + ); + + $listener->onSubscriptions(new OnSubscriptions(new SubscriptionEngineCriteria())); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php new file mode 100644 index 000000000..c04abe409 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php @@ -0,0 +1,271 @@ +createListener($store); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + new RuntimeException('ERROR'), + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: false, + )); + $subscriptionManager->flush(); + + $store->assertNoChanges(); + } + + public function testFailsSubscriptionWhenNoSubscriberFound(): void + { + $exception = new RuntimeException('ERROR'); + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } + + public function testFailsSubscriptionForBatchableSubscriber(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class implements BatchableSubscriber { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + + #[OnFailed] + public function onFailed(): void + { + } + + public function beginBatch(): void + { + } + + public function commitBatch(): void + { + } + + public function rollbackBatch(): void + { + } + + public function forceCommit(): bool + { + return false; + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Booting, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } + + public function testFailsSubscriptionWithoutFailedMethod(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } + + public function testRecoverySucceeds(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + + #[OnFailed] + public function onFailed(): void + { + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active, 1), + ); + } + + public function testRecoveryFails(): void + { + $exception = new RuntimeException('ERROR'); + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[RetryStrategyName('no_retry')] + class { + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + } + + #[OnFailed] + public function onFailed(): void + { + throw new RuntimeException('RECOVERY ERROR'); + } + }; + + $subscription = new Subscription('test', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active); + $store = new DummySubscriptionStore([$subscription]); + + [$listener, $subscriptionManager] = $this->createListener($store, [$subscriber]); + $listener->onHandleMessageError(new OnHandleMessageError( + $subscription, + $exception, + new Message(new ProfileVisited(ProfileId::fromString('test'))), + 1, + transitionToFailed: true, + )); + $subscriptionManager->flush(); + + $store->assertUpdated( + new Subscription( + 'test', + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Failed, + null, + new SubscriptionError('ERROR', Status::Active, ThrowableToErrorContextTransformer::transform($exception)), + ), + ); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php new file mode 100644 index 000000000..b0b3680d9 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -0,0 +1,150 @@ +createMock(RetryStrategy::class); + $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(true); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + $listener->onCommand(new OnCommand(new Run())); + + self::assertCount(1, $store->updatedSubscriptions); + + $updated = $store->updatedSubscriptions[0]; + self::assertEquals($subscriptionId, $updated->id()); + self::assertEquals(Status::Active, $updated->status()); + self::assertEquals(1, $updated->retryAttempt()); + self::assertNull($updated->subscriptionError()); + } + + #[DataProvider('statusProvider')] + public function testShouldNotRetryOtherStatus(string $method, string $status): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::from($status)), + ); + + $store = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->createMock(RetryStrategy::class); + $retryStrategy->expects($this->never())->method('shouldRetry'); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + + $command = match ($method) { + 'setup' => new Setup(), + 'boot' => new Boot(), + 'run' => new Run(), + }; + + $listener->onCommand(new OnCommand($command)); + + $store->assertNoChanges(); + } + + public static function statusProvider(): Generator + { + yield 'setup_booting' => ['setup', 'booting']; + yield 'setup_active' => ['setup', 'active']; + yield 'boot_new' => ['boot', 'new']; + yield 'boot_active' => ['boot', 'active']; + yield 'run_new' => ['run', 'new']; + yield 'run_booting' => ['run', 'booting']; + } + + public function testShouldNotRetry(): void + { + $subscriptionId = 'test'; + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $subscription = new Subscription( + $subscriptionId, + Subscription::DEFAULT_GROUP, + RunMode::FromBeginning, + Status::Error, + 0, + new SubscriptionError('ERROR', Status::Active), + ); + + $store = new DummySubscriptionStore([$subscription]); + + $retryStrategy = $this->createMock(RetryStrategy::class); + $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(false); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + $listener->onCommand(new OnCommand(new Run())); + + $store->assertNoChanges(); + } +} diff --git a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php new file mode 100644 index 000000000..c93f7e030 --- /dev/null +++ b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php @@ -0,0 +1,109 @@ +engine?->run(new Boot()); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Booting), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn( + new Stream([1 => new Message(new ProfileVisited(ProfileId::fromString('test')))]), + ); + + $engine = new NextSubscriptionEngine( + $messageLoader, + $store, + new MetadataSubscriberAccessorRepository([$subscriber]), + logger: new NullLogger(), + ); + + $subscriber->engine = $engine; + + $result = $engine->run(new Boot()); + + self::assertCount(1, $result->errors); + self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); + } + + public function testAlreadyProcessingOnRun(): void + { + $subscriptionId = 'test'; + + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public NextSubscriptionEngine|null $engine = null; + + #[Subscribe(ProfileVisited::class)] + public function handle(): void + { + $this->engine?->run(new Run()); + } + }; + + $store = new DummySubscriptionStore([ + new Subscription($subscriptionId, Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Active), + ]); + + $messageLoader = $this->createMock(MessageLoader::class); + $messageLoader->expects($this->once())->method('load')->with(null)->willReturn( + new Stream([1 => new Message(new ProfileVisited(ProfileId::fromString('test')))]), + ); + + $engine = new NextSubscriptionEngine( + $messageLoader, + $store, + new MetadataSubscriberAccessorRepository([$subscriber]), + logger: new NullLogger(), + ); + + $subscriber->engine = $engine; + + $result = $engine->run(new Run()); + + self::assertCount(1, $result->errors); + self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); + } +} From 329714ff66d839f8af173b0868a39b294b2835fd Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 9 Jun 2026 19:05:52 +0200 Subject: [PATCH 07/12] cs --- .../Engine/Handler/BootHandlerTest.php | 1 + .../Engine/Handler/PauseHandlerTest.php | 1 - .../Engine/Handler/ReactivateHandlerTest.php | 1 + .../Engine/Handler/RefreshHandlerTest.php | 1 + .../Engine/Handler/RemoveHandlerTest.php | 1 + .../Engine/Handler/RunHandlerTest.php | 1 + .../Engine/Handler/SetupHandlerTest.php | 1 + .../Engine/Handler/TeardownHandlerTest.php | 3 +- .../Engine/Listener/BatchSubscriberTest.php | 37 ++++++++++--------- .../Engine/Listener/DetachListenerTest.php | 1 + .../Listener/DiscoverSubscriberTest.php | 1 + .../Engine/Listener/FailSubscriberTest.php | 6 ++- .../Engine/Listener/RetrySubscriberTest.php | 3 +- 13 files changed, 35 insertions(+), 23 deletions(-) diff --git a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php index aeca0f4a0..061c75c42 100644 --- a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php @@ -39,6 +39,7 @@ #[CoversClass(BootHandler::class)] final class BootHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, diff --git a/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php index b8c4fae98..71e48c997 100644 --- a/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/PauseHandlerTest.php @@ -4,7 +4,6 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine\Handler; -use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause as PauseCommand; use Patchlevel\EventSourcing\Subscription\Engine\Handler\PauseHandler; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; diff --git a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php index ffac45118..19a0a2387 100644 --- a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php @@ -21,6 +21,7 @@ #[CoversClass(ReactivateHandler::class)] final class ReactivateHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler(DummySubscriptionStore $store, array $subscribers = []): ReactivateHandler { return new ReactivateHandler( diff --git a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php index d100e55bb..5680f5118 100644 --- a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php @@ -22,6 +22,7 @@ #[CoversClass(RefreshHandler::class)] final class RefreshHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler(DummySubscriptionStore $store, array $subscribers = []): RefreshHandler { return new RefreshHandler( diff --git a/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php index 1d174a1e8..c2b9189e7 100644 --- a/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php @@ -28,6 +28,7 @@ #[CoversClass(RemoveHandler::class)] final class RemoveHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler( DummySubscriptionStore $store, array $subscribers = [], diff --git a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php index 0ef11ed0c..5f53971f0 100644 --- a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php @@ -40,6 +40,7 @@ #[CoversClass(RunHandler::class)] final class RunHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, diff --git a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php index 1ee4d9901..a80158522 100644 --- a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php @@ -30,6 +30,7 @@ #[CoversClass(SetupHandler::class)] final class SetupHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, diff --git a/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php index 1558fbc99..32bad29b2 100644 --- a/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php @@ -10,8 +10,8 @@ use Patchlevel\EventSourcing\Subscription\Cleanup\CleanupTaskHandler; use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropTableTask; use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner; -use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\CleanerNotConfigured; +use Patchlevel\EventSourcing\Subscription\Engine\CleanupRunner; use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown as TeardownCommand; use Patchlevel\EventSourcing\Subscription\Engine\Handler\TeardownHandler; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; @@ -28,6 +28,7 @@ #[CoversClass(TeardownHandler::class)] final class TeardownHandlerTest extends TestCase { + /** @param iterable $subscribers */ private function createHandler( DummySubscriptionStore $store, array $subscribers = [], diff --git a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php index bcfba963a..1a30188bf 100644 --- a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php @@ -34,6 +34,7 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; +use RuntimeException; use Symfony\Component\EventDispatcher\EventDispatcher; #[CoversClass(BatchSubscriber::class)] @@ -181,7 +182,7 @@ public function testBootBatchingSuccessForceCommit(): void public function testBootBatchingWithHandleError(): void { $subscriber = new BatchingSubscriber( - throwForMessage: new \RuntimeException('ERROR'), + throwForMessage: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -207,7 +208,7 @@ public function testBootBatchingWithHandleError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -233,7 +234,7 @@ public function testBootBatchingWithHandleError(): void public function testBootBatchingWithBeginBatchError(): void { $subscriber = new BatchingSubscriber( - throwForBeginBatch: new \RuntimeException('ERROR'), + throwForBeginBatch: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -259,7 +260,7 @@ public function testBootBatchingWithBeginBatchError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -285,7 +286,7 @@ public function testBootBatchingWithBeginBatchError(): void public function testBootBatchingWithCommitBatchError(): void { $subscriber = new BatchingSubscriber( - throwForCommitBatch: new \RuntimeException('ERROR'), + throwForCommitBatch: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -311,7 +312,7 @@ public function testBootBatchingWithCommitBatchError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -337,8 +338,8 @@ public function testBootBatchingWithCommitBatchError(): void public function testBootBatchingWithRollbackBatchError(): void { $subscriber = new BatchingSubscriber( - throwForMessage: new \RuntimeException('ERROR'), - throwForRollbackBatch: new \RuntimeException('ERROR'), + throwForMessage: new RuntimeException('ERROR'), + throwForRollbackBatch: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -364,7 +365,7 @@ public function testBootBatchingWithRollbackBatchError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -482,7 +483,7 @@ public function testRunningBatchingSuccessForceCommit(): void public function testRunningBatchingWithHandleError(): void { $subscriber = new BatchingSubscriber( - throwForMessage: new \RuntimeException('ERROR'), + throwForMessage: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -509,7 +510,7 @@ public function testRunningBatchingWithHandleError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -535,7 +536,7 @@ public function testRunningBatchingWithHandleError(): void public function testRunningBatchingWithBeginBatchError(): void { $subscriber = new BatchingSubscriber( - throwForBeginBatch: new \RuntimeException('ERROR'), + throwForBeginBatch: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -562,7 +563,7 @@ public function testRunningBatchingWithBeginBatchError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -588,7 +589,7 @@ public function testRunningBatchingWithBeginBatchError(): void public function testRunningBatchingWithCommitBatchError(): void { $subscriber = new BatchingSubscriber( - throwForCommitBatch: new \RuntimeException('ERROR'), + throwForCommitBatch: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -615,7 +616,7 @@ public function testRunningBatchingWithCommitBatchError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( @@ -641,8 +642,8 @@ public function testRunningBatchingWithCommitBatchError(): void public function testRunningBatchingWithRollbackBatchError(): void { $subscriber = new BatchingSubscriber( - throwForMessage: new \RuntimeException('ERROR'), - throwForRollbackBatch: new \RuntimeException('ERROR'), + throwForMessage: new RuntimeException('ERROR'), + throwForRollbackBatch: new RuntimeException('ERROR'), ); $store = new DummySubscriptionStore([ @@ -669,7 +670,7 @@ public function testRunningBatchingWithRollbackBatchError(): void $error = $result->errors[0]; self::assertEquals($subscriber::ID, $error->subscriptionId); self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(\RuntimeException::class, $error->throwable); + self::assertInstanceOf(RuntimeException::class, $error->throwable); $store->assertUpdated( new Subscription( diff --git a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php index 3252f38eb..30fe97106 100644 --- a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php @@ -22,6 +22,7 @@ #[CoversClass(DetachListener::class)] final class DetachListenerTest extends TestCase { + /** @param iterable $subscribers */ private function createListener(DummySubscriptionStore $store, array $subscribers = []): DetachListener { return new DetachListener( diff --git a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php index a09d1c602..072016b1b 100644 --- a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php @@ -31,6 +31,7 @@ #[CoversClass(DiscoverSubscriber::class)] final class DiscoverSubscriberTest extends TestCase { + /** @param iterable $subscribers */ private function createListener(DummySubscriptionStore $store, array $subscribers = []): DiscoverSubscriber { return new DiscoverSubscriber( diff --git a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php index c04abe409..d889bab47 100644 --- a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php @@ -30,7 +30,11 @@ #[CoversClass(FailSubscriber::class)] final class FailSubscriberTest extends TestCase { - /** @return array{FailSubscriber, SubscriptionManager} */ + /** + * @param iterable $subscribers + * + * @return array{FailSubscriber, SubscriptionManager} + */ private function createListener(DummySubscriptionStore $store, array $subscribers = []): array { $subscriptionManager = new SubscriptionManager($store); diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php index b0b3680d9..223751142 100644 --- a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -6,7 +6,6 @@ use Generator; use Patchlevel\EventSourcing\Attribute\Subscriber; -use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; @@ -25,11 +24,11 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; -use RuntimeException; #[CoversClass(RetrySubscriber::class)] final class RetrySubscriberTest extends TestCase { + /** @param iterable $subscribers */ private function createListener( DummySubscriptionStore $store, array $subscribers, From 2807a630f3fe0ca36945aff2f89e3f8fb2d281eb Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 9 Jun 2026 20:44:12 +0200 Subject: [PATCH 08/12] remove old code & change to new api --- .../Command/SubscriptionBootCommand.php | 19 +- .../Command/SubscriptionPauseCommand.php | 6 +- .../Command/SubscriptionReactivateCommand.php | 6 +- .../Command/SubscriptionRefreshCommand.php | 15 +- .../Command/SubscriptionRemoveCommand.php | 3 +- .../Command/SubscriptionRunCommand.php | 9 +- .../Command/SubscriptionSetupCommand.php | 3 +- .../Command/SubscriptionTeardownCommand.php | 3 +- .../Engine/CanRefreshSubscriptions.php | 10 - .../Engine/CatchUpSubscriptionEngine.php | 67 +- .../Engine/DefaultSubscriptionEngine.php | 1424 +---- .../LegacyWrapperSubscriptionEngine.php | 146 - .../Engine/NextSubscriptionEngine.php | 243 - .../Engine/SubscriptionEngine.php | 34 +- .../Engine/ThrowOnErrorSubscriptionEngine.php | 74 +- .../RunSubscriptionEngineRepository.php | 8 +- .../IntegrationTest.php | 19 +- .../BasicIntegrationTest.php | 9 +- .../MicroAggregateIntegrationTest.php | 5 +- .../PersonalData/PersonalDataTest.php | 12 +- .../Subscription/SubscriptionTest.php | 120 +- .../Engine/CatchUpSubscriptionEngineTest.php | 180 +- .../Engine/DefaultSubscriptionEngineTest.php | 4785 ----------------- .../Engine/NextSubscriptionEngineTest.php | 12 +- .../ThrowOnErrorSubscriptionEngineTest.php | 264 +- .../RunSubscriptionEngineRepositoryTest.php | 11 +- tests/bootstrap.php | 5 - 27 files changed, 337 insertions(+), 7155 deletions(-) delete mode 100644 src/Subscription/Engine/CanRefreshSubscriptions.php delete mode 100644 src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php delete mode 100644 src/Subscription/Engine/NextSubscriptionEngine.php delete mode 100644 tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php diff --git a/src/Console/Command/SubscriptionBootCommand.php b/src/Console/Command/SubscriptionBootCommand.php index 9e5cb989f..24fb28f63 100644 --- a/src/Console/Command/SubscriptionBootCommand.php +++ b/src/Console/Command/SubscriptionBootCommand.php @@ -5,7 +5,11 @@ namespace Patchlevel\EventSourcing\Console\Command; use Closure; +use LogicException; use Patchlevel\EventSourcing\Console\InputHelper; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; +use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\Worker\DefaultWorker; use Symfony\Component\Console\Attribute\AsCommand; @@ -86,7 +90,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int $criteria = $this->resolveCriteriaIntoCriteriaWithOnlyIds($criteria); if ($setup) { - $this->engine->setup($criteria); + $this->engine->run(new Setup( + $criteria->ids, + $criteria->groups, + )); } $logger = new ConsoleLogger($output); @@ -94,7 +101,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $worker = DefaultWorker::create( function (Closure $stop) use ($criteria, $messageLimit, &$finished): void { - $result = $this->engine->boot($criteria, $messageLimit); + $result = $this->engine->run(new Boot( + $criteria->ids, + $criteria->groups, + $messageLimit, + )); + + if (!$result instanceof ProcessedResult) { + throw new LogicException('Expected ProcessedResult'); + } if (!$result->finished) { return; diff --git a/src/Console/Command/SubscriptionPauseCommand.php b/src/Console/Command/SubscriptionPauseCommand.php index 50aa8a8f8..3f6253252 100644 --- a/src/Console/Command/SubscriptionPauseCommand.php +++ b/src/Console/Command/SubscriptionPauseCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,7 +18,10 @@ final class SubscriptionPauseCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->pause($criteria); + $this->engine->run(new Pause( + $criteria->ids, + $criteria->groups, + )); return 0; } diff --git a/src/Console/Command/SubscriptionReactivateCommand.php b/src/Console/Command/SubscriptionReactivateCommand.php index 81ec2e1f0..5d3690677 100644 --- a/src/Console/Command/SubscriptionReactivateCommand.php +++ b/src/Console/Command/SubscriptionReactivateCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,7 +18,10 @@ final class SubscriptionReactivateCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->reactivate($criteria); + $this->engine->run(new Reactivate( + $criteria->ids, + $criteria->groups, + )); return 0; } diff --git a/src/Console/Command/SubscriptionRefreshCommand.php b/src/Console/Command/SubscriptionRefreshCommand.php index fa6b0cdab..0a41d5986 100644 --- a/src/Console/Command/SubscriptionRefreshCommand.php +++ b/src/Console/Command/SubscriptionRefreshCommand.php @@ -4,14 +4,11 @@ namespace Patchlevel\EventSourcing\Console\Command; -use LogicException; -use Patchlevel\EventSourcing\Subscription\Engine\CanRefreshSubscriptions; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; -use function sprintf; - #[AsCommand( 'event-sourcing:subscription:refresh', 'Refresh subscriptions (run-mode, group)', @@ -20,16 +17,8 @@ final class SubscriptionRefreshCommand extends SubscriptionCommand { protected function execute(InputInterface $input, OutputInterface $output): int { - if (!$this->engine instanceof CanRefreshSubscriptions) { - throw new LogicException(sprintf( - '"%s" does not implement "%s" and cannot call refresh.', - $this->engine::class, - CanRefreshSubscriptions::class, - )); - } - $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->refresh($criteria); + $this->engine->run(new Refresh($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Console/Command/SubscriptionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php index dbff5177b..21f79e6e6 100644 --- a/src/Console/Command/SubscriptionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\OutputStyle; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -27,7 +28,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $this->engine->remove($criteria); + $this->engine->run(new Remove($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Console/Command/SubscriptionRunCommand.php b/src/Console/Command/SubscriptionRunCommand.php index 142f274b4..1a527f1a4 100644 --- a/src/Console/Command/SubscriptionRunCommand.php +++ b/src/Console/Command/SubscriptionRunCommand.php @@ -7,6 +7,9 @@ use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\SubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\Worker\DefaultWorker; use Symfony\Component\Console\Attribute\AsCommand; @@ -95,7 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $worker = DefaultWorker::create( function () use ($criteria, $messageLimit, $sleep): void { - $this->engine->run($criteria, $messageLimit); + $this->engine->run(new Run($criteria->ids, $criteria->groups, $messageLimit)); if (!$this->store instanceof SubscriptionStore) { return; @@ -113,8 +116,8 @@ function () use ($criteria, $messageLimit, $sleep): void { ); if ($rebuild) { - $this->engine->remove($criteria); - $this->engine->boot($criteria); + $this->engine->run(new Remove($criteria->ids, $criteria->groups)); + $this->engine->run(new Boot($criteria->ids, $criteria->groups)); } $supportSubscription = $this->store instanceof SubscriptionStore && $this->store->supportSubscription(); diff --git a/src/Console/Command/SubscriptionSetupCommand.php b/src/Console/Command/SubscriptionSetupCommand.php index 4eb916cb9..069c9f5f4 100644 --- a/src/Console/Command/SubscriptionSetupCommand.php +++ b/src/Console/Command/SubscriptionSetupCommand.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Console\Command; use Patchlevel\EventSourcing\Console\InputHelper; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -34,7 +35,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $skipBooting = InputHelper::bool($input->getOption('skip-booting')); $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->setup($criteria, $skipBooting); + $this->engine->run(new Setup($criteria->ids, $criteria->groups, $skipBooting)); return 0; } diff --git a/src/Console/Command/SubscriptionTeardownCommand.php b/src/Console/Command/SubscriptionTeardownCommand.php index 448429a0b..29ce201a6 100644 --- a/src/Console/Command/SubscriptionTeardownCommand.php +++ b/src/Console/Command/SubscriptionTeardownCommand.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Console\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -17,7 +18,7 @@ final class SubscriptionTeardownCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->teardown($criteria); + $this->engine->run(new Teardown($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Subscription/Engine/CanRefreshSubscriptions.php b/src/Subscription/Engine/CanRefreshSubscriptions.php deleted file mode 100644 index 895a1d7d6..000000000 --- a/src/Subscription/Engine/CanRefreshSubscriptions.php +++ /dev/null @@ -1,10 +0,0 @@ -parent->setup($criteria, $skipBooting); - } - - public function boot(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - $results = []; + $mergedResult = new ProcessedResult(0); $catchupLimit = $this->limit ?? PHP_INT_MAX; for ($i = 0; $i < $catchupLimit; $i++) { - $lastResult = $this->parent->boot($criteria, $limit); + $result = $this->parent->run($command); - $results[] = $lastResult; - - if ($lastResult->processedMessages === 0) { - break; + if (!$result instanceof ProcessedResult) { + return $result; } - } - - return $this->mergeResult(...$results); - } - - public function run(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - $mergedResult = new ProcessedResult(0); - $catchupLimit = $this->limit ?? PHP_INT_MAX; - - for ($i = 0; $i < $catchupLimit; $i++) { - $result = $this->parent->run($criteria, $limit); $mergedResult = $this->mergeResult($mergedResult, $result); if ($result->processedMessages === 0) { @@ -62,45 +42,12 @@ public function run(SubscriptionEngineCriteria|null $criteria = null, int|null $ return $mergedResult; } - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->teardown($criteria); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->remove($criteria); - } - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->reactivate($criteria); - } - - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->parent->pause($criteria); - } - /** @return list */ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array { return $this->parent->subscriptions($criteria); } - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result - { - if (!$this->parent instanceof CanRefreshSubscriptions) { - throw new LogicException(sprintf( - '"%s" does not implement "%s" and cannot call refresh.', - $this->parent::class, - CanRefreshSubscriptions::class, - )); - } - - return $this->parent->refresh($criteria); - } - private function mergeResult(ProcessedResult ...$results): ProcessedResult { $processedMessages = 0; diff --git a/src/Subscription/Engine/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index b82e63d47..e07f0bdcd 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -4,39 +4,56 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -use Patchlevel\EventSourcing\Message\Message; +use InvalidArgumentException; use Patchlevel\EventSourcing\Subscription\Cleanup\Cleaner; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnResult; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptions; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\BootHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\PauseHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\ReactivateHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\RefreshHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\RemoveHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\RunHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\SetupHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Handler\TeardownHandler; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\BatchSubscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\DetachListener; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\DiscoverSubscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailSubscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; -use Patchlevel\EventSourcing\Subscription\RetryStrategy\ConditionalRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; -use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; -use Patchlevel\EventSourcing\Subscription\RunMode; -use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; -use Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber; -use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; -use Throwable; +use Symfony\Component\EventDispatcher\EventDispatcher; +use Symfony\Component\EventDispatcher\EventDispatcherInterface; -use function array_values; -use function count; -use function sprintf; - -final class DefaultSubscriptionEngine implements SubscriptionEngine, CanRefreshSubscriptions +final class DefaultSubscriptionEngine implements SubscriptionEngine { private SubscriptionManager $subscriptionManager; private bool $processing = false; - /** @var array */ - private array $batching = []; - private readonly RetryStrategyRepository $retryStrategyRepository; + /** @var array, Handler> */ + private readonly array $handlers; + public function __construct( private readonly MessageLoader $messageLoader, SubscriptionStore $subscriptionStore, @@ -44,6 +61,7 @@ public function __construct( RetryStrategyRepository|null $retryStrategyRepository = null, private readonly LoggerInterface|null $logger = null, private readonly Cleaner|null $cleaner = null, + private readonly EventDispatcherInterface $eventDispatcher = new EventDispatcher(), ) { $this->subscriptionManager = new SubscriptionManager($subscriptionStore); @@ -55,1311 +73,171 @@ public function __construct( 'no_retry' => new NoRetryStrategy(), ]); } - } - - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - $this->logger?->info( - 'Subscription Engine: Start to setup.', + $cleanupRunner = new CleanupRunner( + $this->subscriptionManager, + $this->cleaner, + $this->logger, ); - $this->discoverNewSubscriptions(); - $this->retrySubscriptions($criteria, Status::New); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::New], - ), - function (SubscriptionCollection $subscriptions) use ($skipBooting): Result { - if (count($subscriptions) === 0) { - $this->logger?->info('Subscription Engine: No subscriptions to setup, finish setup.'); - - return new Result(); - } - - /** @var list $errors */ - $errors = []; - - $latestIndex = $this->messageLoader->lastIndex(); - - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - throw SubscriberNotFound::forSubscriptionId($subscription->id()); - } - - $setupMethod = $subscriber->setupMethod(); - - if (!$setupMethod) { - if ($subscription->runMode() === RunMode::FromNow) { - $subscription->changePosition($latestIndex); - $subscription->active(); - } else { - $skipBooting ? $subscription->active() : $subscription->booting(); - } - - $this->subscriptionManager->update($subscription); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has no setup method, set to %s.', - $subscriber::class, - $subscription->id(), - $subscription->runMode() === RunMode::FromNow || $skipBooting ? 'active' : 'booting', - )); - - continue; - } - - try { - $setupMethod(); - - if ($subscription->runMode() === RunMode::FromNow) { - $subscription->changePosition($latestIndex); - $subscription->active(); - } else { - $skipBooting ? $subscription->active() : $subscription->booting(); - } - - $this->subscriptionManager->update($subscription); - - $this->logger?->debug(sprintf( - 'Subscription Engine: For Subscriber "%s" for "%s" the setup method has been executed, set to %s.', - $subscriber::class, - $subscription->id(), - $subscription->runMode() === RunMode::FromNow || $skipBooting ? 'active' : 'booting', - )); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has an error in the setup method: %s', - $subscriber::class, - $subscription->id(), - $e->getMessage(), - )); - - $this->handleError($subscription, $e); - - $errors[] = new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - } - - return new Result($errors); - }, + $messageProcessor = new MessageProcessor( + $this->subscriberRepository, + $this->eventDispatcher, + $this->logger, ); - } - - public function boot( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult { - if ($this->processing) { - throw new AlreadyProcessing(); - } - - $this->processing = true; - $this->batching = []; - - try { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->logger?->info( - 'Subscription Engine: Start booting.', - ); - - $this->discoverNewSubscriptions(); - $this->retrySubscriptions($criteria, Status::Booting); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Booting], - ), - function (SubscriptionCollection $subscriptions) use ($limit): ProcessedResult { - if (count($subscriptions) === 0) { - $this->logger?->info('Subscription Engine: No subscriptions in booting status, finish booting.'); - - return new ProcessedResult(0, true); - } - - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if ($subscriber) { - continue; - } - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found, skipped.', - $subscription->id(), - ), - ); - - $subscriptions->remove($subscription); - } - - $startIndex = $subscriptions->lowestPosition(); - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Event stream is processed for booting from position %s.', - $startIndex, - ), - ); - - /** @var list $errors */ - $errors = []; - $stream = null; - $messageCounter = 0; - $lastIndex = null; - - try { - $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); - - foreach ($stream as $index => $message) { - $messageCounter++; - $lastIndex = $index; - - foreach ($subscriptions as $subscription) { - if ($subscription->position() >= $index) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue booting.', - $subscription->id(), - $subscription->position(), - $index, - ), - ); - - continue; - } - - $error = $this->handleMessage($index, $message, $subscription); - - if (!$error) { - continue; - } - - $errors[] = $error; - - $subscriptions->remove($subscription); - - if (count($subscriptions) === 0) { - $this->logger?->info( - 'Subscription Engine: No subscriptions in booting status, finish booting.', - ); - - break 2; - } - } - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Current event stream position for booting: %s', - $index, - ), - ); - - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info( - sprintf( - 'Subscription Engine: Message limit (%d) reached, finish booting.', - $limit, - ), - ); - - return new ProcessedResult( - $messageCounter, - false, - $errors, - ); - } - } - } finally { - $stream?->close(); - - if ($lastIndex !== null && $messageCounter > 0) { - foreach ($subscriptions as $subscription) { - $error = $this->ensureCommitBatch($subscription, $lastIndex); - - if ($error) { - $errors[] = $error; - - $subscriptions->remove($subscription); - } - - $this->subscriptionManager->update($subscription); - } - } - } - - $this->logger?->debug('Subscription Engine: End of stream for booting has been reached.'); - - foreach ($subscriptions as $subscription) { - if ($subscription->runMode() === RunMode::Once) { - $subscription->finished(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', - $subscription->id(), - )); - - continue; - } - - $subscription->active(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" has been set to active after booting.', - $subscription->id(), - )); - } - - $this->logger?->info('Subscription Engine: Finish booting.'); - - return new ProcessedResult( - $messageCounter, - true, - $errors, - ); - }, - ); - } finally { - $this->processing = false; - } - } - - public function run( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult { - if ($this->processing) { - throw new AlreadyProcessing(); - } - - $this->processing = true; - $this->batching = []; - - try { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->logger?->info('Subscription Engine: Start processing.'); - - $this->discoverNewSubscriptions(); - $this->markDetachedSubscriptions($criteria); - $this->retrySubscriptions($criteria, Status::Active); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Active], - ), - function (SubscriptionCollection $subscriptions) use ($limit): ProcessedResult { - if (count($subscriptions) === 0) { - $this->logger?->info('Subscription Engine: No subscriptions to process, finish processing.'); - - return new ProcessedResult(0, true); - } - - $startIndex = $subscriptions->lowestPosition(); - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Event stream is processed from position %d.', - $startIndex, - ), - ); - - /** @var list $errors */ - $errors = []; - $stream = null; - $messageCounter = 0; - $lastIndex = null; - - try { - $stream = $this->messageLoader->load($startIndex, $subscriptions->toArray()); - - foreach ($stream as $index => $message) { - $messageCounter++; - $lastIndex = $index; - - foreach ($subscriptions as $subscription) { - if ($subscription->position() >= $index) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscription "%s" is farther than the current position (%d > %d), continue processing.', - $subscription->id(), - $subscription->position(), - $index, - ), - ); - - continue; - } - - $error = $this->handleMessage($index, $message, $subscription); - - if (!$error) { - continue; - } - - $errors[] = $error; - - $subscriptions->remove($subscription); - - if (count($subscriptions) === 0) { - $this->logger?->info( - 'Subscription Engine: No subscriptions in booting status, finish booting.', - ); - - break 2; - } - } - - $this->logger?->debug(sprintf( - 'Subscription Engine: Current event stream position: %s', - $index, - )); - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info( - sprintf( - 'Subscription Engine: Message limit (%d) reached, finish processing.', - $limit, - ), - ); - - return new ProcessedResult($messageCounter, false, $errors); - } - } - } finally { - $stream?->close(); - - if ($lastIndex !== null && $messageCounter > 0) { - foreach ($subscriptions as $subscription) { - $error = $this->ensureCommitBatch($subscription, $lastIndex); - - if ($error) { - $errors[] = $error; - - $subscriptions->remove($subscription); - } - - $this->subscriptionManager->update($subscription); - } - } - } - - foreach ($subscriptions as $subscription) { - if ($subscription->runMode() !== RunMode::Once) { - continue; - } - - $subscription->finished(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" run only once and has been set to finished.', - $subscription->id(), - )); - } - - $this->logger?->info( - sprintf( - 'Subscription Engine: End of stream on position "%d" has been reached, finish processing.', - $lastIndex, - ), - ); - - return new ProcessedResult($messageCounter, true, $errors); - }, - ); - } finally { - $this->processing = false; - } - } - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - $this->logger?->info('Subscription Engine: Start teardown detached subscriptions.'); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Detached], + $this->handlers = [ + Boot::class => new BootHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $messageProcessor, + $this->eventDispatcher, + $this->logger, + ), + Pause::class => new PauseHandler( + $this->subscriptionManager, + $this->logger, + ), + Reactivate::class => new ReactivateHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Refresh::class => new RefreshHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ), + Remove::class => new RemoveHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $cleanupRunner, + $this->logger, + ), + Run::class => new RunHandler( + $this->messageLoader, + $this->subscriptionManager, + $messageProcessor, + $this->eventDispatcher, + $this->logger, + ), + Setup::class => new SetupHandler( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $this->retryStrategyRepository, + $this->logger, + ), + Teardown::class => new TeardownHandler( + $this->subscriptionManager, + $this->subscriberRepository, + $cleanupRunner, + $this->logger, + ), + ]; + + $this->eventDispatcher->addSubscriber( + new DiscoverSubscriber( + $this->messageLoader, + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - /** @var list $errors */ - $errors = []; - - foreach ($subscriptions as $subscription) { - if ($subscription->hasCleanupTasks()) { - $error = $this->cleanup($subscription); - - if ($error) { - $errors[] = $error; - } - - continue; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $this->logger?->warning( - sprintf( - 'Subscription Engine: Subscriber for "%s" to teardown or cleanup not found, skipped.', - $subscription->id(), - ), - ); - - continue; - } - - $teardownMethod = $subscriber->teardownMethod(); - - if (!$teardownMethod) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has no teardown method and was immediately removed.', - $subscriber::class, - $subscription->id(), - ), - ); - - continue; - } - - try { - $teardownMethod(); - - $this->logger?->debug(sprintf( - 'Subscription Engine: For Subscriber "%s" for "%s" the teardown method has been executed and is now prepared to be removed.', - $subscriber::class, - $subscription->id(), - )); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscription "%s" for "%s" has an error in the teardown method, skipped: %s', - $subscriber::class, - $subscription->id(), - $e->getMessage(), - ), - ); - - $errors[] = new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - - continue; - } - - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - ), - ); - } - - $this->logger?->info('Subscription Engine: Finish teardown.'); - - return new Result($errors); - }, ); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, + $this->eventDispatcher->addSubscriber( + new RetrySubscriber( + $this->subscriptionManager, + $this->subscriberRepository, + $this->retryStrategyRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - /** @var list $errors */ - $errors = []; - - foreach ($subscriptions as $subscription) { - if ($subscription->isNew()) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - ), - ); - - continue; - } - - if ($subscription->hasCleanupTasks()) { - $error = $this->cleanup($subscription, true); - - if ($error) { - $errors[] = $error; - } - - continue; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" removed without a suitable subscriber.', - $subscription->id(), - ), - ); - - continue; - } - - $teardownMethod = $subscriber->teardownMethod(); - - if (!$teardownMethod) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), - ); - - continue; - } - - try { - $teardownMethod(); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscriber "%s" teardown method could not be executed: %s', - $subscriber::class, - $e->getMessage(), - ), - ); - - $errors[] = new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - $this->subscriptionManager->remove($subscription); - - $this->logger?->info( - sprintf('Subscription Engine: Subscription "%s" removed.', $subscription->id()), - ); - } - - return new Result($errors); - }, ); - } - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ - Status::Error, - Status::Failed, - Status::Detached, - Status::Paused, - Status::Finished, - ], + $this->eventDispatcher->addSubscriber( + new BatchSubscriber( + $this->subscriberRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found, skipped.', - $subscription->id(), - ), - ); - - continue; - } - - $error = $subscription->subscriptionError(); - - if ($error) { - $subscription->doRetry(); - $subscription->resetRetry(); - - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', - $subscriber::class, - $subscription->id(), - )); - - continue; - } - - $subscription->active(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', - $subscriber::class, - $subscription->id(), - )); - } - - return new Result(); - }, ); - } - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [ - Status::Active, - Status::Booting, - Status::Error, - ], + $this->eventDispatcher->addSubscriber( + new FailSubscriber( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, ), - function (SubscriptionCollection $subscriptions): Result { - /** @var Subscription $subscription */ - foreach ($subscriptions as $subscription) { - $subscription->pause(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" is paused.', - $subscription->id(), - )); - } - - return new Result(); - }, ); - } - /** @return list */ - public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - return $this->subscriptionManager->find( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, + $this->eventDispatcher->addListener( + OnCommand::class, + new DetachListener( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, ), + 32, ); } - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result + public function run(Command $command): Result { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->discoverNewSubscriptions(); - - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - )); - - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - continue; - } - - $changed = false; - - if ($subscription->runMode() !== $subscriber->metadata()->runMode) { - $changed = true; - $oldRunMode = $subscription->runMode(); - $subscription->changeRunMode($subscriber->metadata()->runMode); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" run mode changed from "%s" to "%s".', - $subscription->id(), - $oldRunMode->value, - $subscription->runMode()->value, - ), - ); - } - - if ($subscription->group() !== $subscriber->metadata()->group) { - $changed = true; - $oldGroup = $subscription->group(); - $subscription->changeGroup($subscriber->metadata()->group); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" group changed from "%s" to "%s".', - $subscription->id(), - $oldGroup, - $subscription->group(), - ), - ); - } - - $cleanupTasks = $this->cleanupTasks($subscriber); - - if ($subscription->cleanupTasks() !== $cleanupTasks) { - $changed = true; - $subscription->replaceCleanupTasks($cleanupTasks); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" cleanup tasks changed.', - $subscription->id(), - ), - ); - } + $this->logger?->info( + 'Subscription Engine: ' . $command::class . ' command received.', + ); - if (!$changed) { - continue; - } + if ($this->processing) { + $this->logger?->error( + 'Subscription Engine: Already processing, skip.', + ); - $this->subscriptionManager->update($subscription); + throw new AlreadyProcessing(); } - $this->subscriptionManager->flush(); - - return new Result(); - } - - private function handleMessage(int $index, Message $message, Subscription $subscription): Error|null - { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - throw SubscriberNotFound::forSubscriptionId($subscription->id()); - } + $this->processing = true; - $subscribeMethods = $subscriber->subscribeMethods($message->event()::class); + try { + $handler = $this->handlers[$command::class] ?? null; - if ($subscribeMethods === []) { - if (!isset($this->batching[$subscription->id()])) { - $subscription->changePosition($index); + if ($handler === null) { + throw new InvalidArgumentException('No handler found for command: ' . $command::class); } $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" has no subscribe methods for "%s", continue.', - $subscriber::class, - $subscription->id(), - $message->event()::class, - ), + 'Subscription Engine: ' . $command::class . ' command handled by ' . $handler::class, ); - return null; - } - - $error = $this->checkAndBeginBatch($subscription); + $event = new OnCommand($command); + $this->eventDispatcher->dispatch($event); - if ($error) { - return $error; - } + $result = $handler($command); - try { - foreach ($subscribeMethods as $subscribeMethod) { - $subscribeMethod($message); - } - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', - $subscriber::class, - $subscription->id(), - $message->event()::class, - $e->getMessage(), - ), - ); + $event = new OnResult($command, $result); + $this->eventDispatcher->dispatch($event); - $this->handleError($subscription, $e, $message, $index); - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, + return $result; + } finally { + $this->logger?->info( + 'Subscription Engine: ' . $command::class . ' command processed.', ); - } - - if ($this->shouldCommitBatch($subscription)) { - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" forces to commit batch.', - $subscription->id(), - )); - $this->ensureCommitBatch($subscription, $index); - } - - if (!isset($this->batching[$subscription->id()])) { - $subscription->changePosition($index); + $this->processing = false; } - - $subscription->resetRetry(); - - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', - $subscriber::class, - $subscription->id(), - $message->event()::class, - ), - ); - - return null; } - private function subscriber(string $subscriberId): MetadataSubscriberAccessor|null - { - return $this->subscriberRepository->get($subscriberId); - } - - private function markDetachedSubscriptions(SubscriptionEngineCriteria $criteria): void + /** @return list */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array { - $this->subscriptionManager->findForUpdate( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: [Status::Active, Status::Paused, Status::Finished], - ), - function (SubscriptionCollection $subscriptions): void { - foreach ($subscriptions as $subscription) { - $subscriber = $this->subscriber($subscription->id()); - - if ($subscriber) { - continue; - } - - $subscription->detached(); - $this->subscriptionManager->update($subscription); + $criteria ??= new SubscriptionEngineCriteria(); - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found and has been marked as detached.', - $subscription->id(), - ), - ); - } - }, - ); - } + $this->eventDispatcher->dispatch(new OnSubscriptions($criteria)); - private function retrySubscriptions(SubscriptionEngineCriteria $criteria, Status $previousStatus): void - { - $this->subscriptionManager->findForUpdate( + return $this->subscriptionManager->find( new SubscriptionCriteria( ids: $criteria->ids, groups: $criteria->groups, - status: [Status::Error], ), - function (SubscriptionCollection $subscriptions) use ($previousStatus): void { - /** @var Subscription $subscription */ - foreach ($subscriptions as $subscription) { - $error = $subscription->subscriptionError(); - - if ($error === null) { - continue; - } - - if ($error->previousStatus !== $previousStatus) { - continue; - } - - if (!$this->retryStrategy($subscription)->shouldRetry($subscription)) { - continue; - } - - $subscription->doRetry(); - $this->subscriptionManager->update($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Retry subscription "%s" (%d) and set back to %s.', - $subscription->id(), - $subscription->retryAttempt(), - $subscription->status()->value, - ), - ); - } - }, ); } - - private function discoverNewSubscriptions(): void - { - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria()); - - $latestIndex = null; - - foreach ($this->subscriberRepository->all() as $subscriber) { - foreach ($subscriptions as $subscription) { - if ($subscription->id() === $subscriber->metadata()->id) { - continue 2; - } - } - - $subscription = new Subscription( - $subscriber->metadata()->id, - $subscriber->metadata()->group, - $subscriber->metadata()->runMode, - cleanupTasks: $this->cleanupTasks($subscriber), - ); - - if ($subscriber->setupMethod() === null && $subscriber->metadata()->runMode === RunMode::FromNow) { - if ($latestIndex === null) { - $latestIndex = $this->messageLoader->lastIndex(); - } - - $subscription->changePosition($latestIndex); - $subscription->active(); - } - - $this->subscriptionManager->add($subscription); - - $this->logger?->info( - sprintf( - 'Subscription Engine: New Subscriber "%s" was found and added to the subscription store.', - $subscriber->metadata()->id, - ), - ); - } - - $this->subscriptionManager->flush(); - } - - private function handleError( - Subscription $subscription, - Throwable $throwable, - Message|null $message = null, - int|null $index = null, - ): void { - if ($this->needRollback($subscription)) { - $this->rollback($subscription); - } - - $retryStrategy = $this->retryStrategy($subscription); - - if (!$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($subscription)) { - $subscription->error($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $this->handleFailed($subscription, $throwable, $message, $index); - } - - private function handleFailed( - Subscription $subscription, - Throwable $throwable, - Message|null $message = null, - int|null $index = null, - ): void { - if (!$message || $index === null) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - if ($subscriber->subscriber() instanceof BatchableSubscriber) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $failedMethod = $subscriber->failedMethod(); - - if (!$failedMethod) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - try { - $failedMethod($message, $throwable); - $subscription->changePosition($index); - $subscription->resetRetry(); - - $this->subscriptionManager->update($subscription); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the failed method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - } - } - - private function needRollback(Subscription $subscription): bool - { - return isset($this->batching[$subscription->id()]); - } - - private function rollback(Subscription $subscription): void - { - if (!isset($this->batching[$subscription->id()])) { - throw new UnexpectedError('No batch to rollback.'); - } - - $subscriber = $this->batching[$subscription->id()]; - - unset($this->batching[$subscription->id()]); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" rollback the batch.', - $subscription->id(), - )); - - try { - $subscriber->rollbackBatch(); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the rollback batch method: %s', - $subscription->id(), - $e->getMessage(), - )); - } - } - - private function ensureCommitBatch(Subscription $subscription, int $index): Error|null - { - if (!isset($this->batching[$subscription->id()])) { - return null; - } - - $subscriber = $this->batching[$subscription->id()]; - - unset($this->batching[$subscription->id()]); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" commits the batch.', - $subscription->id(), - )); - - try { - $subscriber->commitBatch(); - $subscription->changePosition($index); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the commit batch method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $this->handleError($subscription, $e); - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - return null; - } - - private function checkAndBeginBatch(Subscription $subscription): Error|null - { - if (isset($this->batching[$subscription->id()])) { - return null; - } - - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber) { - return null; - } - - $realSubscriber = $subscriber->subscriber(); - - if (!$realSubscriber instanceof BatchableSubscriber) { - return null; - } - - $this->batching[$subscription->id()] = $realSubscriber; - - $this->logger?->debug(sprintf( - 'Subscription Engine: Subscriber "%s" starts a new batch.', - $subscription->id(), - )); - - try { - $realSubscriber->beginBatch(); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the begin batch method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $this->handleError($subscription, $e); - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - return null; - } - - private function shouldCommitBatch(Subscription $subscription): bool - { - if (!isset($this->batching[$subscription->id()])) { - return false; - } - - return $this->batching[$subscription->id()]->forceCommit(); - } - - private function retryStrategy(Subscription $subscription): RetryStrategy - { - $subscriber = $this->subscriber($subscription->id()); - - if (!$subscriber instanceof MetadataSubscriberAccessor) { - return $this->retryStrategyRepository->getDefaultRetryStrategy(); - } - - $retryStrategy = $subscriber->metadata()->retryStrategy; - - if ($retryStrategy === null) { - return $this->retryStrategyRepository->getDefaultRetryStrategy(); - } - - return $this->retryStrategyRepository->get($retryStrategy); - } - - private function cleanup(Subscription $subscription, bool $force = false): Error|null - { - if (!$this->cleaner) { - throw new CleanerNotConfigured(); - } - - try { - $this->cleaner->cleanup($subscription); - $this->logger?->debug( - sprintf( - 'Subscription Engine: For Subscription "%s" the cleanup tasks have been executed.', - $subscription->id(), - ), - ); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscription "%s" has an error in the cleanup tasks: %s', - $subscription->id(), - $e->getMessage(), - ), - ); - - if ($force) { - $this->subscriptionManager->remove($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - )); - } - - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - $this->subscriptionManager->remove($subscription); - - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" removed.', - $subscription->id(), - )); - - return null; - } - - /** @return list|null */ - private function cleanupTasks(MetadataSubscriberAccessor $subscriber): array|null - { - $method = $subscriber->cleanupMethod(); - - if (!$method) { - return null; - } - - if (!$this->cleaner) { - throw new CleanerNotConfigured(); - } - - return array_values([...$method()]); - } } diff --git a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php b/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php deleted file mode 100644 index 5ddffa878..000000000 --- a/src/Subscription/Engine/LegacyWrapperSubscriptionEngine.php +++ /dev/null @@ -1,146 +0,0 @@ -engine = new NextSubscriptionEngine( - $messageLoader, - $subscriptionStore, - $subscriberRepository, - $retryStrategyRepository, - $logger, - $cleaner, - ); - } - - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - return $this->engine->run(new Setup( - $criteria->ids, - $criteria->groups, - $skipBooting, - )); - } - - public function boot( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult { - $criteria ??= new SubscriptionEngineCriteria(); - - $result = $this->engine->run(new Boot( - $criteria->ids, - $criteria->groups, - $limit, - )); - - assert($result instanceof ProcessedResult); - - return $result; - } - - public function run( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult { - $criteria ??= new SubscriptionEngineCriteria(); - - $result = $this->engine->run(new Run( - $criteria->ids, - $criteria->groups, - $limit, - )); - - assert($result instanceof ProcessedResult); - - return $result; - } - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - return $this->engine->run(new Teardown( - $criteria->ids, - $criteria->groups, - )); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - return $this->engine->run(new Remove( - $criteria->ids, - $criteria->groups, - )); - } - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - return $this->engine->run(new Reactivate( - $criteria->ids, - $criteria->groups, - )); - } - - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - return $this->engine->run(new Pause( - $criteria->ids, - $criteria->groups, - )); - } - - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result - { - $criteria ??= new SubscriptionEngineCriteria(); - - return $this->engine->run(new Refresh( - $criteria->ids, - $criteria->groups, - )); - } - - /** @return list */ - public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array - { - return $this->engine->subscriptions($criteria); - } -} diff --git a/src/Subscription/Engine/NextSubscriptionEngine.php b/src/Subscription/Engine/NextSubscriptionEngine.php deleted file mode 100644 index 86a337cd8..000000000 --- a/src/Subscription/Engine/NextSubscriptionEngine.php +++ /dev/null @@ -1,243 +0,0 @@ -, Handler> */ - private readonly array $handlers; - - public function __construct( - private readonly MessageLoader $messageLoader, - SubscriptionStore $subscriptionStore, - private readonly SubscriberAccessorRepository $subscriberRepository, - RetryStrategyRepository|null $retryStrategyRepository = null, - private readonly LoggerInterface|null $logger = null, - private readonly Cleaner|null $cleaner = null, - private readonly EventDispatcherInterface $eventDispatcher = new EventDispatcher(), - ) { - $this->subscriptionManager = new SubscriptionManager($subscriptionStore); - - if ($retryStrategyRepository instanceof RetryStrategyRepository) { - $this->retryStrategyRepository = $retryStrategyRepository; - } else { - $this->retryStrategyRepository = new RetryStrategyRepository([ - RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy(), - 'no_retry' => new NoRetryStrategy(), - ]); - } - - $cleanupRunner = new CleanupRunner( - $this->subscriptionManager, - $this->cleaner, - $this->logger, - ); - - $messageProcessor = new MessageProcessor( - $this->subscriberRepository, - $this->eventDispatcher, - $this->logger, - ); - - $this->handlers = [ - Boot::class => new BootHandler( - $this->messageLoader, - $this->subscriptionManager, - $this->subscriberRepository, - $messageProcessor, - $this->eventDispatcher, - $this->logger, - ), - Pause::class => new PauseHandler( - $this->subscriptionManager, - $this->logger, - ), - Reactivate::class => new ReactivateHandler( - $this->subscriptionManager, - $this->subscriberRepository, - $this->logger, - ), - Refresh::class => new RefreshHandler( - $this->subscriptionManager, - $this->subscriberRepository, - $this->logger, - ), - Remove::class => new RemoveHandler( - $this->subscriptionManager, - $this->subscriberRepository, - $cleanupRunner, - $this->logger, - ), - Run::class => new RunHandler( - $this->messageLoader, - $this->subscriptionManager, - $messageProcessor, - $this->eventDispatcher, - $this->logger, - ), - Setup::class => new SetupHandler( - $this->messageLoader, - $this->subscriptionManager, - $this->subscriberRepository, - $this->retryStrategyRepository, - $this->logger, - ), - Teardown::class => new TeardownHandler( - $this->subscriptionManager, - $this->subscriberRepository, - $cleanupRunner, - $this->logger, - ), - ]; - - $this->eventDispatcher->addSubscriber( - new DiscoverSubscriber( - $this->messageLoader, - $this->subscriptionManager, - $this->subscriberRepository, - $this->logger, - ), - ); - - $this->eventDispatcher->addSubscriber( - new RetrySubscriber( - $this->subscriptionManager, - $this->subscriberRepository, - $this->retryStrategyRepository, - $this->logger, - ), - ); - - $this->eventDispatcher->addSubscriber( - new BatchSubscriber( - $this->subscriberRepository, - $this->logger, - ), - ); - - $this->eventDispatcher->addSubscriber( - new FailSubscriber( - $this->subscriptionManager, - $this->subscriberRepository, - $this->logger, - ), - ); - - $this->eventDispatcher->addListener( - OnCommand::class, - new DetachListener( - $this->subscriptionManager, - $this->subscriberRepository, - $this->logger, - ), - 32, - ); - } - - public function run(Command $command): Result - { - $this->logger?->info( - 'Subscription Engine: ' . $command::class . ' command received.', - ); - - if ($this->processing) { - $this->logger?->error( - 'Subscription Engine: Already processing, skip.', - ); - - throw new AlreadyProcessing(); - } - - $this->processing = true; - - try { - $handler = $this->handlers[$command::class] ?? null; - - if ($handler === null) { - throw new InvalidArgumentException('No handler found for command: ' . $command::class); - } - - $this->logger?->debug( - 'Subscription Engine: ' . $command::class . ' command handled by ' . $handler::class, - ); - - $event = new OnCommand($command); - $this->eventDispatcher->dispatch($event); - - $result = $handler($command); - - $event = new OnResult($command, $result); - $this->eventDispatcher->dispatch($event); - - return $result; - } finally { - $this->logger?->info( - 'Subscription Engine: ' . $command::class . ' command processed.', - ); - - $this->processing = false; - } - } - - /** @return list */ - public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array - { - $criteria ??= new SubscriptionEngineCriteria(); - - $this->eventDispatcher->dispatch(new OnSubscriptions($criteria)); - - return $this->subscriptionManager->find( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - ), - ); - } -} diff --git a/src/Subscription/Engine/SubscriptionEngine.php b/src/Subscription/Engine/SubscriptionEngine.php index 3e18be635..973e4b2b6 100644 --- a/src/Subscription/Engine/SubscriptionEngine.php +++ b/src/Subscription/Engine/SubscriptionEngine.php @@ -4,41 +4,13 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Subscription; interface SubscriptionEngine { - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result; - - /** - * @param positive-int|null $limit - * - * @throws SubscriberNotFound - * @throws AlreadyProcessing - */ - public function boot( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult; - - /** - * @param positive-int|null $limit - * - * @throws SubscriberNotFound - * @throws AlreadyProcessing - */ - public function run( - SubscriptionEngineCriteria|null $criteria = null, - int|null $limit = null, - ): ProcessedResult; - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result; - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result; - - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result; - - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result; + /** @throws AlreadyProcessing */ + public function run(Command $command): Result; /** @return list */ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array; diff --git a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php index 2066bd1c2..cd441b3e7 100644 --- a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php +++ b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php @@ -4,51 +4,26 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; -use LogicException; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Subscription; -use function sprintf; - -final class ThrowOnErrorSubscriptionEngine implements SubscriptionEngine, CanRefreshSubscriptions +final class ThrowOnErrorSubscriptionEngine implements SubscriptionEngine { public function __construct( private readonly SubscriptionEngine $parent, ) { } - public function setup(SubscriptionEngineCriteria|null $criteria = null, bool $skipBooting = false): Result - { - return $this->throwOnError($this->parent->setup($criteria, $skipBooting)); - } - - public function boot(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - return $this->throwOnError($this->parent->boot($criteria, $limit)); - } - - public function run(SubscriptionEngineCriteria|null $criteria = null, int|null $limit = null): ProcessedResult - { - return $this->throwOnError($this->parent->run($criteria, $limit)); - } - - public function teardown(SubscriptionEngineCriteria|null $criteria = null): Result + public function run(Command $command): Result { - return $this->throwOnError($this->parent->teardown($criteria)); - } - - public function remove(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->throwOnError($this->parent->remove($criteria)); - } + $result = $this->parent->run($command); + $errors = $result->errors; - public function reactivate(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->throwOnError($this->parent->reactivate($criteria)); - } + if ($errors !== []) { + throw new ErrorDetected($errors); + } - public function pause(SubscriptionEngineCriteria|null $criteria = null): Result - { - return $this->throwOnError($this->parent->pause($criteria)); + return $result; } /** @return list */ @@ -56,35 +31,4 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): { return $this->parent->subscriptions($criteria); } - - public function refresh(SubscriptionEngineCriteria|null $criteria = null): Result - { - if (!$this->parent instanceof CanRefreshSubscriptions) { - throw new LogicException(sprintf( - '"%s" does not implement "%s" and cannot call refresh.', - $this->parent::class, - CanRefreshSubscriptions::class, - )); - } - - return $this->throwOnError($this->parent->refresh($criteria)); - } - - /** - * @param T $result - * - * @return T - * - * @template T of Result|ProcessedResult - */ - private function throwOnError(Result|ProcessedResult $result): Result|ProcessedResult - { - $errors = $result->errors; - - if ($errors !== []) { - throw new ErrorDetected($errors); - } - - return $result; - } } diff --git a/src/Subscription/Repository/RunSubscriptionEngineRepository.php b/src/Subscription/Repository/RunSubscriptionEngineRepository.php index 2f1f39682..b8f4496ad 100644 --- a/src/Subscription/Repository/RunSubscriptionEngineRepository.php +++ b/src/Subscription/Repository/RunSubscriptionEngineRepository.php @@ -8,8 +8,8 @@ use Patchlevel\EventSourcing\Identifier\Identifier; use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Subscription\Engine\AlreadyProcessing; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** * @template T of AggregateRoot @@ -18,7 +18,7 @@ final class RunSubscriptionEngineRepository implements Repository { /** - * @param Repository $repository + * @param Repository $repository * @param list|null $ids * @param list|null $groups * @param positive-int|null $limit @@ -50,11 +50,11 @@ public function save(AggregateRoot $aggregate): void try { $this->engine->run( - new SubscriptionEngineCriteria( + new Run( $this->ids, $this->groups, + $this->limit, ), - $this->limit, ); } catch (AlreadyProcessing) { // do nothing diff --git a/tests/Integration/BankAccountSplitStream/IntegrationTest.php b/tests/Integration/BankAccountSplitStream/IntegrationTest.php index 5e1f8be12..7b41e229e 100644 --- a/tests/Integration/BankAccountSplitStream/IntegrationTest.php +++ b/tests/Integration/BankAccountSplitStream/IntegrationTest.php @@ -13,6 +13,9 @@ use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; @@ -74,8 +77,8 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->setup(); - $engine->boot(); + $engine->run(new Setup()); + $engine->run(new Boot()); $bankAccountId = AccountId::generate(); $bankAccount = BankAccount::create($bankAccountId, 'John'); @@ -83,7 +86,7 @@ public function testSuccessful(): void $bankAccount->addBalance(500); $repository->save($bankAccount); - $engine->run(); + $engine->run(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -122,7 +125,7 @@ public function testSuccessful(): void $bankAccount->addBalance(200); $repository->save($bankAccount); - $engine->run(); + $engine->run(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -189,8 +192,8 @@ public function testRemoveArchived(): void ); $schemaDirector->create(); - $engine->setup(); - $engine->boot(); + $engine->run(new Setup()); + $engine->run(new Boot()); $bankAccountId = AccountId::generate(); $bankAccount = BankAccount::create($bankAccountId, 'John'); @@ -198,7 +201,7 @@ public function testRemoveArchived(): void $bankAccount->addBalance(500); $repository->save($bankAccount); - $engine->run(); + $engine->run(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -237,7 +240,7 @@ public function testRemoveArchived(): void $bankAccount->addBalance(200); $repository->save($bankAccount); - $engine->run(); + $engine->run(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index 45a1f1b1c..fe2285b21 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -24,6 +24,7 @@ use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; @@ -100,7 +101,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -166,7 +167,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -300,7 +301,7 @@ public function testCommandBus(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); @@ -380,7 +381,7 @@ public function testQueryBus(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); diff --git a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php index 7fac99b8b..a0788f76d 100644 --- a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php +++ b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php @@ -12,6 +12,7 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; @@ -76,7 +77,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -142,7 +143,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); diff --git a/tests/Integration/PersonalData/PersonalDataTest.php b/tests/Integration/PersonalData/PersonalDataTest.php index d9b83696f..c8aacf4a2 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/PersonalData/PersonalDataTest.php @@ -16,6 +16,8 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; @@ -132,13 +134,13 @@ public function testRemoveKeyWithEvent(): void new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), ); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $engine->run(); + $engine->run(new Run()); $profile = $repository->load($profileId); @@ -149,7 +151,7 @@ public function testRemoveKeyWithEvent(): void $profile->removePersonalData(); $repository->save($profile); - $engine->run(); + $engine->run(new Run()); $profile = $repository->load($profileId); @@ -214,14 +216,14 @@ public function testRemoveKeyWithEventAndSnapshot(): void new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), ); - $engine->setup(skipBooting: true); + $engine->run(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $profile->changeName('John 2'); $repository->save($profile); - $engine->run(); + $engine->run(new Run()); $profile = $repository->load($profileId); diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index eaffc32d2..a2cd3aa75 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -24,6 +24,13 @@ use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropTableTask; use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner; use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup as SetupCommand; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown as TeardownCommand; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; @@ -48,6 +55,7 @@ use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProjectionWithCleanup; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; +use Psl\Collection\Set; use RuntimeException; use function gc_collect_cycles; @@ -123,11 +131,11 @@ public function testHappyPath(): void $engine->subscriptions(), ); - $result = $engine->setup(); + $result = $engine->run(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->run(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -149,7 +157,7 @@ public function testHappyPath(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -178,7 +186,7 @@ public function testHappyPath(): void self::assertSame($profileId->toString(), $result['id']); self::assertSame('John', $result['name']); - $result = $engine->remove(); + $result = $engine->run(new Remove()); self::assertEquals([], $result->errors); self::assertEquals( @@ -249,11 +257,11 @@ public function testGapResolver(): void $engine->subscriptions(), ); - $result = $engine->setup(); + $result = $engine->run(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->run(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -275,7 +283,7 @@ public function testGapResolver(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -304,7 +312,7 @@ public function testGapResolver(): void self::assertSame($profileId->toString(), $result['id']); self::assertSame('John', $result['name']); - $result = $engine->remove(); + $result = $engine->run(new Remove()); self::assertEquals([], $result->errors); self::assertEquals( @@ -370,10 +378,10 @@ public function testErrorHandling(): void ), ); - $result = $engine->setup(); + $result = $engine->run(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->run(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -392,7 +400,7 @@ public function testErrorHandling(): void // first run, error - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -411,7 +419,7 @@ public function testErrorHandling(): void // second run, time has not passed yet, no retry, no error - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -426,7 +434,7 @@ public function testErrorHandling(): void // third run, time has passed, 1. retry, error again $clock->sleep(5); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -446,7 +454,7 @@ public function testErrorHandling(): void // fourth run, time has passed, 2. retry, max retries reached, failed $clock->sleep(10); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -466,7 +474,7 @@ public function testErrorHandling(): void // fifth run, time has passed, skip failed subscription $clock->sleep(20); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -480,7 +488,7 @@ public function testErrorHandling(): void // reactivated subscription - $engine->reactivate(new SubscriptionEngineCriteria( + $engine->run(new Reactivate( ids: ['error_producer'], )); @@ -492,7 +500,7 @@ public function testErrorHandling(): void // sixth run, error again - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -514,7 +522,7 @@ public function testErrorHandling(): void $clock->sleep(5); $subscriber->subscribeError = false; - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -571,7 +579,7 @@ public function testSelfRecovery(): void ), ); - $result = $engine->setup(skipBooting: true); + $result = $engine->run(new SetupCommand(skipBooting: true)); self::assertEquals([], $result->errors); // add data @@ -585,7 +593,7 @@ public function testSelfRecovery(): void // first run, failed -> self recovery - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -609,7 +617,7 @@ public function testSelfRecovery(): void // second run, failed -> self recovery failed $subscriber->onFailedError = true; - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -692,10 +700,10 @@ public function subscribe(): void ), ); - $result = $engine->setup(); + $result = $engine->run(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->run(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -712,7 +720,7 @@ public function subscribe(): void $subscriber->subscribeError = true; - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -795,7 +803,7 @@ public function testProcessor(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $engine->run(); + $engine->run(new Run()); $subscriptions = $engine->subscriptions(); @@ -856,8 +864,8 @@ public function testBlueGreenDeployment(): void // Deploy first version - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->run(new SetupCommand()); + $firstEngine->run(new Boot()); self::assertEquals( [ @@ -877,7 +885,7 @@ public function testBlueGreenDeployment(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(); + $firstEngine->run(new Run()); self::assertEquals( [ @@ -901,8 +909,8 @@ public function testBlueGreenDeployment(): void new MetadataSubscriberAccessorRepository([new ProfileNewProjection($this->projectionConnection)]), ); - $secondEngine->setup(); - $secondEngine->boot(); + $secondEngine->run(new SetupCommand()); + $secondEngine->run(new Boot()); self::assertEquals( [ @@ -928,7 +936,7 @@ public function testBlueGreenDeployment(): void // switch traffic - $secondEngine->run(); + $secondEngine->run(new Run()); self::assertEquals( [ @@ -954,7 +962,7 @@ public function testBlueGreenDeployment(): void // shutdown first version - $firstEngine->teardown(); + $firstEngine->run(new TeardownCommand()); self::assertEquals( [ @@ -1012,8 +1020,8 @@ public function testBlueGreenDeploymentRollback(): void // Deploy first version - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->run(new SetupCommand()); + $firstEngine->run(new Boot()); self::assertEquals( [ @@ -1033,7 +1041,7 @@ public function testBlueGreenDeploymentRollback(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(); + $firstEngine->run(new Run()); self::assertEquals( [ @@ -1057,8 +1065,8 @@ public function testBlueGreenDeploymentRollback(): void new MetadataSubscriberAccessorRepository([new ProfileNewProjection($this->projectionConnection)]), ); - $secondEngine->setup(); - $secondEngine->boot(); + $secondEngine->run(new SetupCommand()); + $secondEngine->run(new Boot()); self::assertEquals( [ @@ -1084,7 +1092,7 @@ public function testBlueGreenDeploymentRollback(): void // switch traffic - $secondEngine->run(); + $secondEngine->run(new Run()); self::assertEquals( [ @@ -1110,8 +1118,8 @@ public function testBlueGreenDeploymentRollback(): void // rollback - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->run(new SetupCommand()); + $firstEngine->run(new Boot()); self::assertEquals( [ @@ -1137,13 +1145,13 @@ public function testBlueGreenDeploymentRollback(): void // reactivating detached subscription - $firstEngine->reactivate(new SubscriptionEngineCriteria( + $firstEngine->run(new Reactivate( ids: ['profile_1'], )); // switch traffic - $firstEngine->run(); + $firstEngine->run(new Run()); self::assertEquals( [ @@ -1169,7 +1177,7 @@ public function testBlueGreenDeploymentRollback(): void // shutdown second version - $secondEngine->teardown(); + $secondEngine->run(new TeardownCommand()); self::assertEquals( [ @@ -1234,8 +1242,8 @@ public function testCleanup(): void // Deploy first version - $firstEngine->setup(); - $firstEngine->boot(); + $firstEngine->run(new SetupCommand()); + $firstEngine->run(new Boot()); self::assertEquals( [ @@ -1256,7 +1264,7 @@ public function testCleanup(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(); + $firstEngine->run(new Run()); self::assertEquals( [ @@ -1282,8 +1290,8 @@ public function testCleanup(): void cleaner: $cleaner, ); - $secondEngine->setup(); - $secondEngine->boot(); + $secondEngine->run(new SetupCommand()); + $secondEngine->run(new Boot()); self::assertEquals( [ @@ -1310,7 +1318,7 @@ public function testCleanup(): void // switch traffic - $secondEngine->run(); + $secondEngine->run(new Run()); self::assertEquals( [ @@ -1337,7 +1345,7 @@ public function testCleanup(): void // shutdown second version (with cleanup) - $secondEngine->teardown(); + $secondEngine->run(new TeardownCommand()); self::assertEquals( [ @@ -1410,11 +1418,11 @@ public function testLookup(): void $subscriberRepository, ); - $result = $engine->setup(); + $result = $engine->run(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->boot(); + $result = $engine->run(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -1423,7 +1431,7 @@ public function testLookup(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -1439,7 +1447,7 @@ public function testLookup(): void $profile->promoteToAdmin(); $repository->save($profile); - $result = $engine->run(); + $result = $engine->run(new Run()); self::assertEquals(2, $result->processedMessages); self::assertEquals([], $result->errors); @@ -1491,7 +1499,7 @@ class { $subscriberRepository, ); - $engine->setup(); + $engine->run(new SetupCommand()); $subscriptions = $engine->subscriptions(); self::assertCount(1, $subscriptions); @@ -1512,7 +1520,7 @@ class { $newSubscriberRepository, ); - $engine->refresh(); + $engine->run(new Refresh()); $subscriptions = $engine->subscriptions(); self::assertCount(1, $subscriptions); diff --git a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php index f871c0d69..5c767ba32 100644 --- a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php @@ -4,12 +4,10 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; -use LogicException; -use Patchlevel\EventSourcing\Subscription\Engine\CanRefreshSubscriptions; use Patchlevel\EventSourcing\Subscription\Engine\CatchUpSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; -use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -20,87 +18,17 @@ #[CoversClass(CatchUpSubscriptionEngine::class)] final class CatchUpSubscriptionEngineTest extends TestCase { - public function testSetup(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('setup')->with($criteria, true)->willReturn($expectedResult); - $result = $engine->setup($criteria, true); - - self::assertSame($expectedResult, $result); - } - - public function testBootFinished(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(0); - - $parent->expects($this->exactly(1))->method('boot')->with($criteria, 42)->willReturn($expectedResult); - $result = $engine->boot($criteria, 42); - - self::assertEquals($expectedResult, $result); - } - - public function testBootSecondTime(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $error = new Error( - 'foo', - 'bar', - new RuntimeException('baz'), - ); - - $parent->expects($this->exactly(2))->method('boot')->with($criteria, 42)->willReturn( - new ProcessedResult(1), - new ProcessedResult(0, true, [$error]), - ); - - $result = $engine->boot($criteria, 42); - - self::assertEquals(new ProcessedResult(1, true, [$error]), $result); - } - - public function testBootLimit(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent, 2); - $criteria = new SubscriptionEngineCriteria(); - - $parent->expects($this->exactly(2))->method('boot')->with($criteria, 42)->willReturn( - new ProcessedResult(1), - new ProcessedResult(1), - ); - - $result = $engine->boot($criteria, 42); - - self::assertEquals(new ProcessedResult(2), $result); - } - public function testRunFinished(): void { $parent = $this->createMock(SubscriptionEngine::class); $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); $expectedResult = new ProcessedResult(0); + $command = new Run(); - $parent->expects($this->once())->method('run')->with($criteria, 42)->willReturn($expectedResult); - $result = $engine->run($criteria, 42); + $parent->expects($this->once())->method('run')->with($command)->willReturn($expectedResult); + $result = $engine->run($command); self::assertEquals($expectedResult, $result); } @@ -110,7 +38,7 @@ public function testRunSecondTime(): void $parent = $this->createMock(SubscriptionEngine::class); $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); + $command = new Run(); $error = new Error( 'foo', @@ -118,11 +46,11 @@ public function testRunSecondTime(): void new RuntimeException('baz'), ); - $parent->expects($this->exactly(2))->method('run')->with($criteria, 42)->willReturn( + $parent->expects($this->exactly(2))->method('run')->with($command)->willReturn( new ProcessedResult(1, true, [$error]), new ProcessedResult(0), ); - $result = $engine->run($criteria, 42); + $result = $engine->run($command); self::assertEquals(new ProcessedResult(1, false, [$error]), $result); } @@ -132,78 +60,18 @@ public function testRunLimit(): void $parent = $this->createMock(SubscriptionEngine::class); $engine = new CatchUpSubscriptionEngine($parent, 2); - $criteria = new SubscriptionEngineCriteria(); + $command = new Run(); - $parent->expects($this->exactly(2))->method('run')->with($criteria, 42)->willReturn( + $parent->expects($this->exactly(2))->method('run')->with($command)->willReturn( new ProcessedResult(1), new ProcessedResult(1), ); - $result = $engine->run($criteria, 42); + $result = $engine->run($command); self::assertEquals(new ProcessedResult(2), $result); } - public function testTeardown(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('teardown')->with($criteria)->willReturn($expectedResult); - $result = $engine->teardown($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRemove(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('remove')->with($criteria)->willReturn($expectedResult); - $result = $engine->remove($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testReactivate(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('reactivate')->with($criteria)->willReturn($expectedResult); - $result = $engine->reactivate($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testPause(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('pause')->with($criteria)->willReturn($expectedResult); - $result = $engine->pause($criteria); - - self::assertSame($expectedResult, $result); - } - public function testSubscriptions(): void { $parent = $this->createMock(SubscriptionEngine::class); @@ -218,32 +86,4 @@ public function testSubscriptions(): void self::assertEquals($expectedSubscriptions, $subscriptions); } - - public function testRefreshSubscriptions(): void - { - $parent = $this->createMockForIntersectionOfInterfaces([ - SubscriptionEngine::class, - CanRefreshSubscriptions::class, - ]); - - $engine = new CatchUpSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('refresh')->with($criteria)->willReturn($expectedResult); - $result = $engine->refresh($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRefreshSubscriptionsNotSupported(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new CatchUpSubscriptionEngine($parent); - - $this->expectException(LogicException::class); - $engine->refresh(); - } } diff --git a/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php deleted file mode 100644 index 45f01aec2..000000000 --- a/tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php +++ /dev/null @@ -1,4785 +0,0 @@ -createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with(0, []); - - $store = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $store, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - $store->assertNoChanges(); - self::assertEquals([], $result->errors); - } - - public function testSetupWithoutCreateMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ); - } - - public function testSetupWithCreateMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $created = false; - - #[Setup] - public function create(): void - { - $this->created = true; - } - }; - - $subscriptionStore = new DummySubscriptionStore(); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ); - - self::assertTrue($subscriber->created); - } - - public function testSetupWithCreateError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription($subscriptionId), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::New, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testSetupWithCreateErrorNoRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription($subscriptionId), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::New, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testSetupWithCreateErrorRecoveryNotPossible(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Setup] - public function create(): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription($subscriptionId), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - null, - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::New, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testSetupWithSkipBooting(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(null, true); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testSetupWithFromNow(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::New, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::Active, - 1, - ), - ); - } - - public function testSetupWithFromNowWithEmptyStream(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::New, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(0); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::Active, - 0, - ), - ); - } - - public function testSetupWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(3)) - ->method('find') - ->willReturnCallback( - new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Error])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::New])], - [], - ], - ]), - ); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->setup($engineCriteria); - } - - public function testNothingToBoot(): void - { - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any(), $this->any()); - - $store = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $store, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $store->assertNoChanges(); - } - - public function testBootDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any(), $this->any()); - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - $subscriptionStore->assertNoUpdated(); - } - - public function testBootWithSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - } - - public function testBootWithError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithErrorNoRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithErrorAndRecovery(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ); - } - - public function testBootWithErrorAndRecoveryFailed(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - throw new RuntimeException('RECOVERY ERROR'); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithErrorAndRecoveryFailedBecauseBatching(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class implements BatchableSubscriber { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - - public function beginBatch(): void - { - // TODO: Implement beginBatch() method. - } - - public function commitBatch(): void - { - // TODO: Implement commitBatch() method. - } - - public function rollbackBatch(): void - { - // TODO: Implement rollbackBatch() method. - } - - public function forceCommit(): bool - { - return false; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testBootWithLimit(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(new SubscriptionEngineCriteria(), 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - } - - public function testBootingWithSkip(): void - { - $subscriptionId1 = 'test1'; - $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionId2 = 'test2'; - $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber1->message); - self::assertNull($subscriber2->message); - } - - public function testBootingWithGabInIndex(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @var list */ - public array $messages = []; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->messages[] = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 3 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 3, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->messages); - } - - public function testBootingWithOnlyOnce(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::Once)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Finished, - 1, - ), - ); - - self::assertEquals($message1, $subscriber->message); - } - - public function testBootAlreadyProcessing(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public SubscriptionEngine|null $engine = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(): void - { - $this->engine?->boot(); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $subscriber->engine = $engine; - - $result = $engine->boot(); - - self::assertCount(1, $result->errors); - self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); - } - - public function testBootTwice(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->exactly(2)) - ->method('load') - ->willReturnCallback(new ReturnCallback([ - [ - [0, [$subscription]], - new Stream([1 => $message]), - ], - [ - [1, [$subscription]], - new Stream([]), - ], - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(limit: 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - - $subscriptionStore->reset(); - $result = $engine->boot(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - } - - public function testBootWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testBootBatchingSuccess(): void - { - $subscriber = new BatchingSubscriber(); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingSuccessForceCommit(): void - { - $subscriber = new BatchingSubscriber( - forceCommitAfterMessages: 1, - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 2 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 2, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->receivedMessages); - self::assertSame(2, $subscriber->beginBatchCalled); - self::assertSame(2, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithHandleError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithBeginBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForBeginBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), - ), - ), - ); - - self::assertSame([], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithCommitBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForCommitBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testBootBatchingWithRollbackBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - throwForRollbackBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->boot(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testBootWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(3)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Error])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Booting])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->boot($engineCriteria); - } - - public function testRunDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testRunning(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - } - - public function testRunningWithLimit(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0) - ->willReturn(new Stream([1 => $message1, 2 => $message2])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(new SubscriptionEngineCriteria(), 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message1, $subscriber->message); - } - - public function testRunningWithSkip(): void - { - $subscriptionId1 = 'test1'; - $subscriber1 = new #[Subscriber('test1', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionId2 = 'test2'; - $subscriber2 = new #[Subscriber('test2', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(null)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId1, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - new Subscription( - $subscriptionId2, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber1->message); - self::assertNull($subscriber2->message); - } - - public function testRunningWithError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testRunningWithErrorNoRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testRunningWithErrorAndRecovery(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - } - - public function testRunningWithErrorAndRecoveryFailed(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - #[RetryStrategyName('no_retry')] - class { - public function __construct( - public readonly RuntimeException $exception = new RuntimeException('ERROR'), - ) { - } - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - throw $this->exception; - } - - #[OnFailed] - public function onFailed(): void - { - throw new RuntimeException('RECOVERY ERROR'); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Failed, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->exception), - ), - ), - ); - } - - public function testRunningMarkDetached(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - 0, - ), - ); - } - - public function testRunningWithoutActiveSubscribers(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->never())->method('load')->with($this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testRunningWithGabInIndex(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @var list */ - public array $messages = []; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->messages[] = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 3 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 3, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->messages); - } - - public function testRunningWithOnlyOnce(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::Once)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::Once, - Status::Finished, - 1, - ), - ); - - self::assertEquals($message1, $subscriber->message); - } - - public function testRunningAlreadyProcessing(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public SubscriptionEngine|null $engine = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(): void - { - $this->engine?->run(); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message1])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $subscriber->engine = $engine; - - $result = $engine->run(); - - self::assertCount(1, $result->errors); - self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); - } - - public function testRunningTwice(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - - #[Subscribe(ProfileVisited::class)] - public function handle(Message $message): void - { - $this->message = $message; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->exactly(2)) - ->method('load') - ->willReturnCallback(new ReturnCallback([ - [ - [0, [$subscription]], - new Stream([1 => $message]), - ], - [ - [1, [$subscription]], - new Stream([]), - ], - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(limit: 1); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(false, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame($message, $subscriber->message); - - $subscriptionStore->reset(); - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testRunningBatchingSuccess(): void - { - $subscriber = new BatchingSubscriber(); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 1, - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingSuccessForceCommit(): void - { - $subscriber = new BatchingSubscriber( - forceCommitAfterMessages: 1, - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message1 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - $message2 = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([ - 1 => $message1, - 2 => $message2, - ])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(2, $result->processedMessages); - self::assertEquals(true, $result->finished); - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoAdded(); - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - 2, - ), - ); - - self::assertSame([$message1, $message2], $subscriber->receivedMessages); - self::assertSame(2, $subscriber->beginBatchCalled); - self::assertSame(2, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithHandleError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithBeginBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForBeginBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), - ), - ), - ); - - self::assertSame([], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithCommitBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForCommitBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(1, $subscriber->commitBatchCalled); - self::assertSame(0, $subscriber->rollbackBatchCalled); - } - - public function testRunningBatchingWithRollbackBatchError(): void - { - $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), - throwForRollbackBatch: new RuntimeException('ERROR'), - ); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertEquals(true, $result->finished); - - $error = $result->errors[0]; - - self::assertEquals($subscriber::ID, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriber::ID, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError( - 'ERROR', - Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), - ), - ), - ); - - self::assertSame([$message], $subscriber->receivedMessages); - self::assertSame(1, $subscriber->beginBatchCalled); - self::assertSame(0, $subscriber->commitBatchCalled); - self::assertSame(1, $subscriber->rollbackBatchCalled); - } - - public function testRunWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(4)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Active, Status::Paused, Status::Finished])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Error])], - [], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Active])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->run($engineCriteria); - } - - public function testTeardownDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testTeardownWithoutTeardownMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testTeardownWithSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - self::assertTrue($subscriber->dropped); - } - - public function testTeardownWithSubscriberAndError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public Message|null $message = null; - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - throw new RuntimeException('ERROR'); - } - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertNoChanges(); - } - - public function testTeardownWithoutSubscriber(): void - { - $subscriberId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriberId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testTeardownWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Detached])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->teardown($engineCriteria); - } - - public function testTeardownWithCleanupAndWithoutCleaner(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [ - new DropTableTask('test'), - ], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $this->expectException(CleanerNotConfigured::class); - - $engine->teardown(); - } - - public function testTeardownWithCleanupAndSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testTeardownWithCleanupAndWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->teardown(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testTeardownWithCleanupHandlerError(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ), - ]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->teardown(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertInstanceOf(CleanupFailed::class, $error->throwable); - - $subscriptionStore->assertNoChanges(); - } - - public function testRemoveDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testRemoveWithSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - self::assertTrue($subscriber->dropped); - } - - public function testRemoveWithoutDropMethod(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithSubscriberAndError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - throw new RuntimeException('ERROR'); - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveNewSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - public bool $dropped = false; - - #[Teardown] - public function drop(): void - { - $this->dropped = true; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - self::assertFalse($subscriber->dropped); - } - - public function testRemoveWithoutSubscriber(): void - { - $subscriberId = 'test'; - - $subscription = new Subscription( - $subscriberId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ); - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->remove($engineCriteria); - } - - public function testRemoveWithCleanupAndWithoutCleaner(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [ - new DropTableTask('test'), - ], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $this->expectException(CleanerNotConfigured::class); - - $engine->remove(); - } - - public function testRemoveWithCleanupAndSubscriber(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - return [ - new DropTableTask('test'), - ]; - } - }; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithCleanupAndWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->remove(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertNoUpdated(); - $subscriptionStore->assertRemoved($subscription); - } - - public function testRemoveWithCleanupHandlerError(): void - { - $subscriptionId = 'test'; - - $task = new DropTableTask('test'); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - cleanupTasks: [$task], - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $cleanupHandler = $this->createMock(CleanupTaskHandler::class); - $cleanupHandler->expects($this->once())->method('supports')->with($task)->willReturn(true); - $cleanupHandler->expects($this->once())->method('__invoke')->with($task)->willThrowException(new RuntimeException('ERROR')); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - cleaner: new DefaultCleaner([$cleanupHandler]), - ); - - $result = $engine->remove(); - - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertInstanceOf(CleanupFailed::class, $error->throwable); - - $subscriptionStore->assertRemoved($subscription); - } - - public function testReactiveDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testReactivateError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::New), - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - 0, - ), - ); - } - - public function testReactivateDetached(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Detached, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testReactivatePaused(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testReactivateFinished(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Finished, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->reactivate(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testReactivateWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [ - new SubscriptionCriteria( - ['id1'], - ['group1'], - [ - Status::Error, - Status::Failed, - Status::Detached, - Status::Paused, - Status::Finished, - ], - ), - ], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->reactivate($engineCriteria); - } - - public function testPauseDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - } - - public function testPauseBooting(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Booting, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ); - } - - public function testPauseActive(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ); - } - - public function testPauseError(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::New), - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - 0, - new SubscriptionError('ERROR', Status::New), - ), - ); - } - - public function testPauseWithoutSubscriber(): void - { - $subscriptionId = 'test'; - - $subscriptionStore = new DummySubscriptionStore([ - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Active, - ), - ]); - - $streamableStore = $this->createMock(MessageLoader::class); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([]), - logger: new NullLogger(), - ); - - $result = $engine->pause(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertUpdated( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Paused, - ), - ); - } - - public function testPauseWithCriteria(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = $this->createMock(SubscriptionStore::class); - $subscriptionStore->expects($this->exactly(2)) - ->method('find') - ->willReturnCallback(new ReturnCallback([ - [ - [new SubscriptionCriteria()], - [new Subscription('id1')], - ], - [ - [new SubscriptionCriteria(['id1'], ['group1'], [Status::Active, Status::Booting, Status::Error])], - [], - ], - ])); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load') - ->with($this->any(), $this->any()); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engineCriteria = new SubscriptionEngineCriteria( - ids: ['id1'], - groups: ['group1'], - ); - - $engine->pause($engineCriteria); - } - - public function testGetSubscriptionAndDiscoverNewSubscribers(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $subscriptions = $engine->subscriptions(); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::New, - ), - ); - - self::assertCount(1, $subscriptions); - $subscription = $subscriptions[0]; - - self::assertEquals($subscriptionId, $subscription->id()); - self::assertEquals(Subscription::DEFAULT_GROUP, $subscription->group()); - self::assertEquals(RunMode::FromBeginning, $subscription->runMode()); - self::assertEquals(Status::New, $subscription->status()); - } - - public function testRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - #[Subscribe(ProfileVisited::class)] - public function subscribe(): void - { - throw new RuntimeException('ERROR2'); - } - }; - - $message = new Message(new ProfileVisited(ProfileId::fromString('test'))); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('load')->with(0)->willReturn(new Stream([1 => $message])); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::Active), - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $retryStrategy = $this->createMock(RetryStrategy::class); - $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(true); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - new RetryStrategyRepository([RetryStrategyRepository::DEFAULT_STRATEGY_NAME => $retryStrategy]), - new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(1, $result->processedMessages); - self::assertCount(1, $result->errors); - - $error = $result->errors[0]; - - self::assertEquals($subscriptionId, $error->subscriptionId); - self::assertEquals('ERROR2', $error->message); - self::assertInstanceOf(RuntimeException::class, $error->throwable); - - self::assertCount(2, $subscriptionStore->updatedSubscriptions); - - [$update1, $update2] = $subscriptionStore->updatedSubscriptions; - - self::assertEquals($subscriptionId, $update1->id()); - self::assertEquals(Subscription::DEFAULT_GROUP, $update1->group()); - self::assertEquals(RunMode::FromBeginning, $update1->runMode()); - self::assertEquals(Status::Active, $update1->status()); - self::assertEquals(0, $update1->position()); - self::assertNull($update1->subscriptionError()); - self::assertEquals(1, $update1->retryAttempt()); - - self::assertEquals(Status::Error, $update2->status()); - self::assertEquals(Status::Active, $update2->subscriptionError()?->previousStatus); - self::assertEquals('ERROR2', $update2->subscriptionError()?->errorMessage); - self::assertEquals(1, $update2->retryAttempt()); - } - - #[DataProvider('statusProvider')] - public function testShouldNotRetryOtherStatus(string $method, string $status): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::from($status)), - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $retryStrategy = $this->createMock(RetryStrategy::class); - $retryStrategy->expects($this->never())->method('shouldRetry')->with($subscription); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - new RetryStrategyRepository([RetryStrategyRepository::DEFAULT_STRATEGY_NAME => $retryStrategy]), - new NullLogger(), - ); - - $result = match ($method) { - 'setup' => $engine->setup(), - 'boot' => $engine->boot(), - 'run' => $engine->run(), - }; - - self::assertCount(0, $result->errors); - $subscriptionStore->assertNoChanges(); - } - - public static function statusProvider(): Generator - { - yield 'setup_booting' => ['setup', 'booting']; - yield 'setup_active' => ['setup', 'active']; - yield 'boot_new' => ['boot', 'new']; - yield 'boot_active' => ['boot', 'active']; - yield 'run_new' => ['run', 'new']; - yield 'run_booting' => ['run', 'booting']; - } - - public function testShouldNotRetry(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - - $subscription = new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromBeginning, - Status::Error, - 0, - new SubscriptionError('ERROR', Status::Active), - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $retryStrategy = $this->createMock(RetryStrategy::class); - $retryStrategy->method('shouldRetry')->with($subscription)->willReturn(false); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - new RetryStrategyRepository([RetryStrategyRepository::DEFAULT_STRATEGY_NAME => $retryStrategy]), - new NullLogger(), - ); - - $result = $engine->run(); - - self::assertEquals(0, $result->processedMessages); - self::assertCount(0, $result->errors); - - $subscriptionStore->assertNoChanges(); - } - - public function testDontLockGetSubscriptions(): void - { - $subscriber = new #[Subscriber('id1', RunMode::FromNow)] - class { - }; - - $subscriptionStore = $this->createMock(LockableSubscriptionStore::class); - $subscriptionStore - ->expects($this->never()) - ->method('inLock'); - - $subscriptionStore - ->expects($this->exactly(2)) - ->method('find') - ->with(new SubscriptionCriteria()) - ->willReturn([new Subscription('id1')]); - - $subscriptionStore - ->expects($this->never()) - ->method('remove') - ->with($this->isInstanceOf(Subscription::class)); - - $subscriptionStore - ->expects($this->never()) - ->method('add') - ->with($this->isInstanceOf(Subscription::class)); - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore - ->expects($this->never()) - ->method('load'); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $engine->subscriptions(); - } - - public function testFromNowWithoutSetupDirectActive(): void - { - $subscriptionId = 'test'; - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $streamableStore = $this->createMock(MessageLoader::class); - $streamableStore->expects($this->once())->method('lastIndex')->willReturn(1); - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $streamableStore, - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - ); - - $result = $engine->setup(); - - self::assertEquals([], $result->errors); - - $subscriptionStore->assertAdded( - new Subscription( - $subscriptionId, - Subscription::DEFAULT_GROUP, - RunMode::FromNow, - Status::Active, - 1, - ), - ); - } - - public function testRefreshSubscriptionsNoChanges(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'default')] - class { - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertNoChanges(); - } - - public function testRefreshSubscriptionsChangeRunMode(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromNow)] - class { - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'default', - RunMode::FromNow, - Status::Active, - ), - ); - } - - public function testRefreshSubscriptionsChangeGroup(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning, group: 'new-group')] - class { - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'new-group', - RunMode::FromBeginning, - Status::Active, - ), - ); - } - - public function testRefreshSubscriptionsChangeCleanupTasks(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - yield new DropTableTask('test'); - } - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - cleanupTasks: [new DropTableTask('test')], - ), - ); - } - - public function testRefreshSubscriptionsMultipleChanges(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromNow, group: 'new-group')] - class { - /** @return iterable */ - #[Cleanup] - public function cleanup(): iterable - { - yield new DropTableTask('test'); - } - }; - - $subscription = new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test', - 'new-group', - RunMode::FromNow, - Status::Active, - cleanupTasks: [new DropTableTask('test')], - ), - ); - } - - public function testRefreshSubscriptionsWithCriteria(): void - { - $subscriber1 = new #[Subscriber('test1', RunMode::FromNow)] - class { - }; - - $subscriber2 = new #[Subscriber('test2', RunMode::FromNow)] - class { - }; - - $subscription1 = new Subscription( - 'test1', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscription2 = new Subscription( - 'test2', - 'default', - RunMode::FromBeginning, - Status::Active, - ); - - $subscriptionStore = new DummySubscriptionStore([$subscription1, $subscription2]); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(new SubscriptionEngineCriteria(['test1'])); - - $subscriptionStore->assertUpdated( - new Subscription( - 'test1', - 'default', - RunMode::FromNow, - Status::Active, - ), - ); - - self::assertCount(1, $subscriptionStore->updatedSubscriptions); - } - - public function testRefreshSubscriptionsDiscoverNewSubscribers(): void - { - $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] - class { - }; - - $subscriptionStore = new DummySubscriptionStore(); - - $engine = new DefaultSubscriptionEngine( - $this->createMock(MessageLoader::class), - $subscriptionStore, - new MetadataSubscriberAccessorRepository([$subscriber]), - logger: new NullLogger(), - cleaner: $this->createMock(Cleaner::class), - ); - - $engine->refresh(); - - $subscriptionStore->assertAdded( - new Subscription( - 'test', - 'default', - RunMode::FromBeginning, - Status::New, - ), - ); - } -} diff --git a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php index c93f7e030..d244f6169 100644 --- a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php @@ -12,7 +12,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; -use Patchlevel\EventSourcing\Subscription\Engine\NextSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; @@ -24,7 +24,7 @@ use PHPUnit\Framework\TestCase; use Psr\Log\NullLogger; -#[CoversClass(NextSubscriptionEngine::class)] +#[CoversClass(DefaultSubscriptionEngine::class)] final class NextSubscriptionEngineTest extends TestCase { public function testAlreadyProcessingOnBoot(): void @@ -35,7 +35,7 @@ public function testAlreadyProcessingOnBoot(): void $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] class { - public NextSubscriptionEngine|null $engine = null; + public DefaultSubscriptionEngine|null $engine = null; #[Subscribe(ProfileVisited::class)] public function handle(): void @@ -53,7 +53,7 @@ public function handle(): void new Stream([1 => new Message(new ProfileVisited(ProfileId::fromString('test')))]), ); - $engine = new NextSubscriptionEngine( + $engine = new DefaultSubscriptionEngine( $messageLoader, $store, new MetadataSubscriberAccessorRepository([$subscriber]), @@ -74,7 +74,7 @@ public function testAlreadyProcessingOnRun(): void $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] class { - public NextSubscriptionEngine|null $engine = null; + public DefaultSubscriptionEngine|null $engine = null; #[Subscribe(ProfileVisited::class)] public function handle(): void @@ -92,7 +92,7 @@ public function handle(): void new Stream([1 => new Message(new ProfileVisited(ProfileId::fromString('test')))]), ); - $engine = new NextSubscriptionEngine( + $engine = new DefaultSubscriptionEngine( $messageLoader, $store, new MetadataSubscriberAccessorRepository([$subscriber]), diff --git a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php index 50d888f7c..b95500128 100644 --- a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php @@ -4,12 +4,10 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; -use LogicException; -use Patchlevel\EventSourcing\Subscription\Engine\CanRefreshSubscriptions; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\ErrorDetected; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; -use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; @@ -20,235 +18,39 @@ #[CoversClass(ThrowOnErrorSubscriptionEngine::class)] final class ThrowOnErrorSubscriptionEngineTest extends TestCase { - public function testSetupSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('setup')->with($criteria, true)->willReturn($expectedResult); - $result = $engine->setup($criteria, true); - - self::assertSame($expectedResult, $result); - } - - public function testSetupError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('setup')->with($criteria, false)->willReturn($expectedResult); - $engine->setup($criteria); - } - - public function testBootSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(5); - - $parent->expects($this->once())->method('boot')->with($criteria, 10)->willReturn($expectedResult); - $result = $engine->boot($criteria, 10); - - self::assertSame($expectedResult, $result); - } - - public function testBootError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(5, false, [ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('boot')->with($criteria, 10)->willReturn($expectedResult); - $engine->boot($criteria, 10); - } - public function testRunSuccess(): void { $parent = $this->createMock(SubscriptionEngine::class); $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); $expectedResult = new ProcessedResult(5); - $parent->expects($this->once())->method('run')->with($criteria, 10)->willReturn($expectedResult); - $result = $engine->run($criteria, 10); - - self::assertSame($expectedResult, $result); - } - - public function testRunError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new ProcessedResult(5, false, [ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); + $command = new Setup(); - $parent->expects($this->once())->method('run')->with($criteria, 10)->willReturn($expectedResult); - $engine->run($criteria, 10); - } - - public function testTeardownSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('teardown')->with($criteria)->willReturn($expectedResult); - $result = $engine->teardown($criteria); + $parent->expects($this->once())->method('run')->with($command)->willReturn($expectedResult); + $result = $engine->run($command); self::assertSame($expectedResult, $result); } - public function testTeardownError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('teardown')->with($criteria)->willReturn($expectedResult); - $engine->teardown($criteria); - } - - public function testRemoveSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('remove')->with($criteria)->willReturn($expectedResult); - $result = $engine->remove($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRemoveError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - - $parent->expects($this->once())->method('remove')->with($criteria)->willReturn($expectedResult); - $engine->remove($criteria); - } - - public function testReactivateSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('reactivate')->with($criteria)->willReturn($expectedResult); - $result = $engine->reactivate($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testReactivateError(): void + public function testRunError(): void { $this->expectException(ErrorDetected::class); $parent = $this->createMock(SubscriptionEngine::class); $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - new Error('id2', 'error2', new RuntimeException('error2')), - ]); - $parent->expects($this->once())->method('reactivate')->with($criteria)->willReturn($expectedResult); - $engine->reactivate($criteria); - } + $command = new Setup(); - public function testPauseSuccess(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('pause')->with($criteria)->willReturn($expectedResult); - $result = $engine->pause($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testPauseError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ + $expectedResult = new ProcessedResult(5, false, [ new Error('id1', 'error1', new RuntimeException('error1')), new Error('id2', 'error2', new RuntimeException('error2')), ]); - $parent->expects($this->once())->method('pause')->with($criteria)->willReturn($expectedResult); - $engine->pause($criteria); + $parent->expects($this->once())->method('run')->with($command)->willReturn($expectedResult); + $engine->run($command); } public function testSubscriptions(): void @@ -263,52 +65,4 @@ public function testSubscriptions(): void self::assertSame([], $result); } - - public function testRefreshSubscriptionsSuccess(): void - { - $parent = $this->createMockForIntersectionOfInterfaces([ - SubscriptionEngine::class, - CanRefreshSubscriptions::class, - ]); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result(); - - $parent->expects($this->once())->method('refresh')->with($criteria)->willReturn($expectedResult); - $result = $engine->refresh($criteria); - - self::assertSame($expectedResult, $result); - } - - public function testRefreshSubscriptionsError(): void - { - $this->expectException(ErrorDetected::class); - - $parent = $this->createMockForIntersectionOfInterfaces([ - SubscriptionEngine::class, - CanRefreshSubscriptions::class, - ]); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - $criteria = new SubscriptionEngineCriteria(); - - $expectedResult = new Result([ - new Error('id1', 'error1', new RuntimeException('error1')), - ]); - - $parent->expects($this->once())->method('refresh')->with($criteria)->willReturn($expectedResult); - $engine->refresh($criteria); - } - - public function testRefreshSubscriptionsNotSupported(): void - { - $parent = $this->createMock(SubscriptionEngine::class); - - $engine = new ThrowOnErrorSubscriptionEngine($parent); - - $this->expectException(LogicException::class); - $engine->refresh(); - } } diff --git a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php index 77f025c16..e718c70ea 100644 --- a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php +++ b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php @@ -6,6 +6,7 @@ use Patchlevel\EventSourcing\Repository\Repository; use Patchlevel\EventSourcing\Subscription\Engine\AlreadyProcessing; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; @@ -66,9 +67,10 @@ public function testHas(): void public function testSave(): void { - $criteria = new SubscriptionEngineCriteria( + $command = new Run( ['id1', 'id2'], ['group1', 'group2'], + 42, ); $aggregate = Profile::createProfile( @@ -80,7 +82,7 @@ public function testSave(): void $defaultRepository->expects($this->once())->method('save')->with($aggregate); $engine = $this->createMock(SubscriptionEngine::class); - $engine->expects($this->once())->method('run')->with($criteria, 42)->willReturn(new ProcessedResult(21)); + $engine->expects($this->once())->method('run')->with($command)->willReturn(new ProcessedResult(21)); $repository = new RunSubscriptionEngineRepository( $defaultRepository, @@ -95,9 +97,10 @@ public function testSave(): void public function testSaveWithAlreadyProcessing(): void { - $criteria = new SubscriptionEngineCriteria( + $command = new Run( ['id1', 'id2'], ['group1', 'group2'], + 42, ); $aggregate = Profile::createProfile( @@ -109,7 +112,7 @@ public function testSaveWithAlreadyProcessing(): void $defaultRepository->expects($this->once())->method('save')->with($aggregate); $engine = $this->createMock(SubscriptionEngine::class); - $engine->expects($this->once())->method('run')->with($criteria, 42)->willThrowException(new AlreadyProcessing()); + $engine->expects($this->once())->method('run')->with($command)->willThrowException(new AlreadyProcessing()); $repository = new RunSubscriptionEngineRepository( $defaultRepository, diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 72d9b9bca..06fdcb453 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -2,9 +2,4 @@ declare(strict_types=1); -use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\LegacyWrapperSubscriptionEngine; - require __DIR__ . '/../vendor/autoload.php'; - -class_alias(LegacyWrapperSubscriptionEngine::class, DefaultSubscriptionEngine::class); From beec937b2260bb14e28e13f1c833266a0390c7ae Mon Sep 17 00:00:00 2001 From: David Badura Date: Tue, 9 Jun 2026 20:46:04 +0200 Subject: [PATCH 09/12] rename run() into execute() --- docs/subscription.md | 2 +- .../Command/SubscriptionBootCommand.php | 4 +- .../Command/SubscriptionPauseCommand.php | 2 +- .../Command/SubscriptionReactivateCommand.php | 2 +- .../Command/SubscriptionRefreshCommand.php | 2 +- .../Command/SubscriptionRemoveCommand.php | 2 +- .../Command/SubscriptionRunCommand.php | 6 +- .../Command/SubscriptionSetupCommand.php | 2 +- .../Command/SubscriptionTeardownCommand.php | 2 +- .../Engine/CatchUpSubscriptionEngine.php | 4 +- .../Engine/DefaultSubscriptionEngine.php | 2 +- .../Engine/SubscriptionEngine.php | 2 +- .../Engine/ThrowOnErrorSubscriptionEngine.php | 4 +- .../RunSubscriptionEngineRepository.php | 2 +- .../IntegrationTest.php | 16 +-- .../BasicIntegrationTest.php | 8 +- .../MicroAggregateIntegrationTest.php | 4 +- .../PersonalData/PersonalDataTest.php | 10 +- .../Subscription/SubscriptionTest.php | 112 +++++++++--------- .../Engine/CatchUpSubscriptionEngineTest.php | 12 +- .../Engine/NextSubscriptionEngineTest.php | 8 +- .../ThrowOnErrorSubscriptionEngineTest.php | 8 +- .../RunSubscriptionEngineRepositoryTest.php | 4 +- 23 files changed, 110 insertions(+), 110 deletions(-) diff --git a/docs/subscription.md b/docs/subscription.md index 2601babf7..8b719fc6f 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1305,7 +1305,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->run(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new SubscriptionEngineCriteria()); ``` ### Teardown diff --git a/src/Console/Command/SubscriptionBootCommand.php b/src/Console/Command/SubscriptionBootCommand.php index 24fb28f63..c91d8c309 100644 --- a/src/Console/Command/SubscriptionBootCommand.php +++ b/src/Console/Command/SubscriptionBootCommand.php @@ -90,7 +90,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $criteria = $this->resolveCriteriaIntoCriteriaWithOnlyIds($criteria); if ($setup) { - $this->engine->run(new Setup( + $this->engine->execute(new Setup( $criteria->ids, $criteria->groups, )); @@ -101,7 +101,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $worker = DefaultWorker::create( function (Closure $stop) use ($criteria, $messageLimit, &$finished): void { - $result = $this->engine->run(new Boot( + $result = $this->engine->execute(new Boot( $criteria->ids, $criteria->groups, $messageLimit, diff --git a/src/Console/Command/SubscriptionPauseCommand.php b/src/Console/Command/SubscriptionPauseCommand.php index 3f6253252..1deebcdde 100644 --- a/src/Console/Command/SubscriptionPauseCommand.php +++ b/src/Console/Command/SubscriptionPauseCommand.php @@ -18,7 +18,7 @@ final class SubscriptionPauseCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->run(new Pause( + $this->engine->execute(new Pause( $criteria->ids, $criteria->groups, )); diff --git a/src/Console/Command/SubscriptionReactivateCommand.php b/src/Console/Command/SubscriptionReactivateCommand.php index 5d3690677..dd6b01bc9 100644 --- a/src/Console/Command/SubscriptionReactivateCommand.php +++ b/src/Console/Command/SubscriptionReactivateCommand.php @@ -18,7 +18,7 @@ final class SubscriptionReactivateCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->run(new Reactivate( + $this->engine->execute(new Reactivate( $criteria->ids, $criteria->groups, )); diff --git a/src/Console/Command/SubscriptionRefreshCommand.php b/src/Console/Command/SubscriptionRefreshCommand.php index 0a41d5986..6af998722 100644 --- a/src/Console/Command/SubscriptionRefreshCommand.php +++ b/src/Console/Command/SubscriptionRefreshCommand.php @@ -18,7 +18,7 @@ final class SubscriptionRefreshCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->run(new Refresh($criteria->ids, $criteria->groups)); + $this->engine->execute(new Refresh($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Console/Command/SubscriptionRemoveCommand.php b/src/Console/Command/SubscriptionRemoveCommand.php index 21f79e6e6..5b8c345b5 100644 --- a/src/Console/Command/SubscriptionRemoveCommand.php +++ b/src/Console/Command/SubscriptionRemoveCommand.php @@ -28,7 +28,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } } - $this->engine->run(new Remove($criteria->ids, $criteria->groups)); + $this->engine->execute(new Remove($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Console/Command/SubscriptionRunCommand.php b/src/Console/Command/SubscriptionRunCommand.php index 1a527f1a4..64da55092 100644 --- a/src/Console/Command/SubscriptionRunCommand.php +++ b/src/Console/Command/SubscriptionRunCommand.php @@ -98,7 +98,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $worker = DefaultWorker::create( function () use ($criteria, $messageLimit, $sleep): void { - $this->engine->run(new Run($criteria->ids, $criteria->groups, $messageLimit)); + $this->engine->execute(new Run($criteria->ids, $criteria->groups, $messageLimit)); if (!$this->store instanceof SubscriptionStore) { return; @@ -116,8 +116,8 @@ function () use ($criteria, $messageLimit, $sleep): void { ); if ($rebuild) { - $this->engine->run(new Remove($criteria->ids, $criteria->groups)); - $this->engine->run(new Boot($criteria->ids, $criteria->groups)); + $this->engine->execute(new Remove($criteria->ids, $criteria->groups)); + $this->engine->execute(new Boot($criteria->ids, $criteria->groups)); } $supportSubscription = $this->store instanceof SubscriptionStore && $this->store->supportSubscription(); diff --git a/src/Console/Command/SubscriptionSetupCommand.php b/src/Console/Command/SubscriptionSetupCommand.php index 069c9f5f4..c537ea3ff 100644 --- a/src/Console/Command/SubscriptionSetupCommand.php +++ b/src/Console/Command/SubscriptionSetupCommand.php @@ -35,7 +35,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $skipBooting = InputHelper::bool($input->getOption('skip-booting')); $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->run(new Setup($criteria->ids, $criteria->groups, $skipBooting)); + $this->engine->execute(new Setup($criteria->ids, $criteria->groups, $skipBooting)); return 0; } diff --git a/src/Console/Command/SubscriptionTeardownCommand.php b/src/Console/Command/SubscriptionTeardownCommand.php index 29ce201a6..fa4228667 100644 --- a/src/Console/Command/SubscriptionTeardownCommand.php +++ b/src/Console/Command/SubscriptionTeardownCommand.php @@ -18,7 +18,7 @@ final class SubscriptionTeardownCommand extends SubscriptionCommand protected function execute(InputInterface $input, OutputInterface $output): int { $criteria = $this->subscriptionEngineCriteria($input); - $this->engine->run(new Teardown($criteria->ids, $criteria->groups)); + $this->engine->execute(new Teardown($criteria->ids, $criteria->groups)); return 0; } diff --git a/src/Subscription/Engine/CatchUpSubscriptionEngine.php b/src/Subscription/Engine/CatchUpSubscriptionEngine.php index 8a0484f5c..6f6ee5729 100644 --- a/src/Subscription/Engine/CatchUpSubscriptionEngine.php +++ b/src/Subscription/Engine/CatchUpSubscriptionEngine.php @@ -19,14 +19,14 @@ public function __construct( ) { } - public function run(Command $command): Result + public function execute(Command $command): Result { $mergedResult = new ProcessedResult(0); $catchupLimit = $this->limit ?? PHP_INT_MAX; for ($i = 0; $i < $catchupLimit; $i++) { - $result = $this->parent->run($command); + $result = $this->parent->execute($command); if (!$result instanceof ProcessedResult) { return $result; diff --git a/src/Subscription/Engine/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index e07f0bdcd..9a9ada233 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -181,7 +181,7 @@ public function __construct( ); } - public function run(Command $command): Result + public function execute(Command $command): Result { $this->logger?->info( 'Subscription Engine: ' . $command::class . ' command received.', diff --git a/src/Subscription/Engine/SubscriptionEngine.php b/src/Subscription/Engine/SubscriptionEngine.php index 973e4b2b6..b081695f3 100644 --- a/src/Subscription/Engine/SubscriptionEngine.php +++ b/src/Subscription/Engine/SubscriptionEngine.php @@ -10,7 +10,7 @@ interface SubscriptionEngine { /** @throws AlreadyProcessing */ - public function run(Command $command): Result; + public function execute(Command $command): Result; /** @return list */ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array; diff --git a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php index cd441b3e7..054e8a195 100644 --- a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php +++ b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php @@ -14,9 +14,9 @@ public function __construct( ) { } - public function run(Command $command): Result + public function execute(Command $command): Result { - $result = $this->parent->run($command); + $result = $this->parent->execute($command); $errors = $result->errors; if ($errors !== []) { diff --git a/src/Subscription/Repository/RunSubscriptionEngineRepository.php b/src/Subscription/Repository/RunSubscriptionEngineRepository.php index b8f4496ad..059680472 100644 --- a/src/Subscription/Repository/RunSubscriptionEngineRepository.php +++ b/src/Subscription/Repository/RunSubscriptionEngineRepository.php @@ -49,7 +49,7 @@ public function save(AggregateRoot $aggregate): void $this->repository->save($aggregate); try { - $this->engine->run( + $this->engine->execute( new Run( $this->ids, $this->groups, diff --git a/tests/Integration/BankAccountSplitStream/IntegrationTest.php b/tests/Integration/BankAccountSplitStream/IntegrationTest.php index 7b41e229e..e6b03db65 100644 --- a/tests/Integration/BankAccountSplitStream/IntegrationTest.php +++ b/tests/Integration/BankAccountSplitStream/IntegrationTest.php @@ -77,8 +77,8 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->run(new Setup()); - $engine->run(new Boot()); + $engine->execute(new Setup()); + $engine->execute(new Boot()); $bankAccountId = AccountId::generate(); $bankAccount = BankAccount::create($bankAccountId, 'John'); @@ -86,7 +86,7 @@ public function testSuccessful(): void $bankAccount->addBalance(500); $repository->save($bankAccount); - $engine->run(new Run()); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -125,7 +125,7 @@ public function testSuccessful(): void $bankAccount->addBalance(200); $repository->save($bankAccount); - $engine->run(new Run()); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -192,8 +192,8 @@ public function testRemoveArchived(): void ); $schemaDirector->create(); - $engine->run(new Setup()); - $engine->run(new Boot()); + $engine->execute(new Setup()); + $engine->execute(new Boot()); $bankAccountId = AccountId::generate(); $bankAccount = BankAccount::create($bankAccountId, 'John'); @@ -201,7 +201,7 @@ public function testRemoveArchived(): void $bankAccount->addBalance(500); $repository->save($bankAccount); - $engine->run(new Run()); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', @@ -240,7 +240,7 @@ public function testRemoveArchived(): void $bankAccount->addBalance(200); $repository->save($bankAccount); - $engine->run(new Run()); + $engine->execute(new Run()); $result = $this->connection->fetchAssociative( 'SELECT * FROM projection_bank_account WHERE id = ?', diff --git a/tests/Integration/BasicImplementation/BasicIntegrationTest.php b/tests/Integration/BasicImplementation/BasicIntegrationTest.php index fe2285b21..8d0bbcf75 100644 --- a/tests/Integration/BasicImplementation/BasicIntegrationTest.php +++ b/tests/Integration/BasicImplementation/BasicIntegrationTest.php @@ -101,7 +101,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -167,7 +167,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -301,7 +301,7 @@ public function testCommandBus(): void ); $schemaDirector->create(); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); @@ -381,7 +381,7 @@ public function testQueryBus(): void ); $schemaDirector->create(); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); diff --git a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php index a0788f76d..ae4bb3336 100644 --- a/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php +++ b/tests/Integration/MicroAggregate/MicroAggregateIntegrationTest.php @@ -77,7 +77,7 @@ public function testSuccessful(): void ); $schemaDirector->create(); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); @@ -143,7 +143,7 @@ public function testSnapshot(): void ); $schemaDirector->create(); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); diff --git a/tests/Integration/PersonalData/PersonalDataTest.php b/tests/Integration/PersonalData/PersonalDataTest.php index c8aacf4a2..3c71f04be 100644 --- a/tests/Integration/PersonalData/PersonalDataTest.php +++ b/tests/Integration/PersonalData/PersonalDataTest.php @@ -134,13 +134,13 @@ public function testRemoveKeyWithEvent(): void new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), ); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $engine->run(new Run()); + $engine->execute(new Run()); $profile = $repository->load($profileId); @@ -151,7 +151,7 @@ public function testRemoveKeyWithEvent(): void $profile->removePersonalData(); $repository->save($profile); - $engine->run(new Run()); + $engine->execute(new Run()); $profile = $repository->load($profileId); @@ -216,14 +216,14 @@ public function testRemoveKeyWithEventAndSnapshot(): void new MetadataSubscriberAccessorRepository([new DeletePersonalDataProcessor($cipherKeyStore)]), ); - $engine->run(new Setup(skipBooting: true)); + $engine->execute(new Setup(skipBooting: true)); $profileId = ProfileId::generate(); $profile = Profile::create($profileId, 'John'); $profile->changeName('John 2'); $repository->save($profile); - $engine->run(new Run()); + $engine->execute(new Run()); $profile = $repository->load($profileId); diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index a2cd3aa75..b1e9586a9 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -131,11 +131,11 @@ public function testHappyPath(): void $engine->subscriptions(), ); - $result = $engine->run(new SetupCommand()); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->run(new Boot()); + $result = $engine->execute(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -157,7 +157,7 @@ public function testHappyPath(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -186,7 +186,7 @@ public function testHappyPath(): void self::assertSame($profileId->toString(), $result['id']); self::assertSame('John', $result['name']); - $result = $engine->run(new Remove()); + $result = $engine->execute(new Remove()); self::assertEquals([], $result->errors); self::assertEquals( @@ -257,11 +257,11 @@ public function testGapResolver(): void $engine->subscriptions(), ); - $result = $engine->run(new SetupCommand()); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->run(new Boot()); + $result = $engine->execute(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -283,7 +283,7 @@ public function testGapResolver(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -312,7 +312,7 @@ public function testGapResolver(): void self::assertSame($profileId->toString(), $result['id']); self::assertSame('John', $result['name']); - $result = $engine->run(new Remove()); + $result = $engine->execute(new Remove()); self::assertEquals([], $result->errors); self::assertEquals( @@ -378,10 +378,10 @@ public function testErrorHandling(): void ), ); - $result = $engine->run(new SetupCommand()); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->run(new Boot()); + $result = $engine->execute(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -400,7 +400,7 @@ public function testErrorHandling(): void // first run, error - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -419,7 +419,7 @@ public function testErrorHandling(): void // second run, time has not passed yet, no retry, no error - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -434,7 +434,7 @@ public function testErrorHandling(): void // third run, time has passed, 1. retry, error again $clock->sleep(5); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -454,7 +454,7 @@ public function testErrorHandling(): void // fourth run, time has passed, 2. retry, max retries reached, failed $clock->sleep(10); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -474,7 +474,7 @@ public function testErrorHandling(): void // fifth run, time has passed, skip failed subscription $clock->sleep(20); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -488,7 +488,7 @@ public function testErrorHandling(): void // reactivated subscription - $engine->run(new Reactivate( + $engine->execute(new Reactivate( ids: ['error_producer'], )); @@ -500,7 +500,7 @@ public function testErrorHandling(): void // sixth run, error again - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -522,7 +522,7 @@ public function testErrorHandling(): void $clock->sleep(5); $subscriber->subscribeError = false; - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -579,7 +579,7 @@ public function testSelfRecovery(): void ), ); - $result = $engine->run(new SetupCommand(skipBooting: true)); + $result = $engine->execute(new SetupCommand(skipBooting: true)); self::assertEquals([], $result->errors); // add data @@ -593,7 +593,7 @@ public function testSelfRecovery(): void // first run, failed -> self recovery - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -617,7 +617,7 @@ public function testSelfRecovery(): void // second run, failed -> self recovery failed $subscriber->onFailedError = true; - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -700,10 +700,10 @@ public function subscribe(): void ), ); - $result = $engine->run(new SetupCommand()); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->run(new Boot()); + $result = $engine->execute(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -720,7 +720,7 @@ public function subscribe(): void $subscriber->subscribeError = true; - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertCount(1, $result->errors); @@ -803,7 +803,7 @@ public function testProcessor(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $engine->run(new Run()); + $engine->execute(new Run()); $subscriptions = $engine->subscriptions(); @@ -864,8 +864,8 @@ public function testBlueGreenDeployment(): void // Deploy first version - $firstEngine->run(new SetupCommand()); - $firstEngine->run(new Boot()); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -885,7 +885,7 @@ public function testBlueGreenDeployment(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(new Run()); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -909,8 +909,8 @@ public function testBlueGreenDeployment(): void new MetadataSubscriberAccessorRepository([new ProfileNewProjection($this->projectionConnection)]), ); - $secondEngine->run(new SetupCommand()); - $secondEngine->run(new Boot()); + $secondEngine->execute(new SetupCommand()); + $secondEngine->execute(new Boot()); self::assertEquals( [ @@ -936,7 +936,7 @@ public function testBlueGreenDeployment(): void // switch traffic - $secondEngine->run(new Run()); + $secondEngine->execute(new Run()); self::assertEquals( [ @@ -962,7 +962,7 @@ public function testBlueGreenDeployment(): void // shutdown first version - $firstEngine->run(new TeardownCommand()); + $firstEngine->execute(new TeardownCommand()); self::assertEquals( [ @@ -1020,8 +1020,8 @@ public function testBlueGreenDeploymentRollback(): void // Deploy first version - $firstEngine->run(new SetupCommand()); - $firstEngine->run(new Boot()); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -1041,7 +1041,7 @@ public function testBlueGreenDeploymentRollback(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(new Run()); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -1065,8 +1065,8 @@ public function testBlueGreenDeploymentRollback(): void new MetadataSubscriberAccessorRepository([new ProfileNewProjection($this->projectionConnection)]), ); - $secondEngine->run(new SetupCommand()); - $secondEngine->run(new Boot()); + $secondEngine->execute(new SetupCommand()); + $secondEngine->execute(new Boot()); self::assertEquals( [ @@ -1092,7 +1092,7 @@ public function testBlueGreenDeploymentRollback(): void // switch traffic - $secondEngine->run(new Run()); + $secondEngine->execute(new Run()); self::assertEquals( [ @@ -1118,8 +1118,8 @@ public function testBlueGreenDeploymentRollback(): void // rollback - $firstEngine->run(new SetupCommand()); - $firstEngine->run(new Boot()); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -1145,13 +1145,13 @@ public function testBlueGreenDeploymentRollback(): void // reactivating detached subscription - $firstEngine->run(new Reactivate( + $firstEngine->execute(new Reactivate( ids: ['profile_1'], )); // switch traffic - $firstEngine->run(new Run()); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -1177,7 +1177,7 @@ public function testBlueGreenDeploymentRollback(): void // shutdown second version - $secondEngine->run(new TeardownCommand()); + $secondEngine->execute(new TeardownCommand()); self::assertEquals( [ @@ -1242,8 +1242,8 @@ public function testCleanup(): void // Deploy first version - $firstEngine->run(new SetupCommand()); - $firstEngine->run(new Boot()); + $firstEngine->execute(new SetupCommand()); + $firstEngine->execute(new Boot()); self::assertEquals( [ @@ -1264,7 +1264,7 @@ public function testCleanup(): void $profile = Profile::create(ProfileId::generate(), 'John'); $repository->save($profile); - $firstEngine->run(new Run()); + $firstEngine->execute(new Run()); self::assertEquals( [ @@ -1290,8 +1290,8 @@ public function testCleanup(): void cleaner: $cleaner, ); - $secondEngine->run(new SetupCommand()); - $secondEngine->run(new Boot()); + $secondEngine->execute(new SetupCommand()); + $secondEngine->execute(new Boot()); self::assertEquals( [ @@ -1318,7 +1318,7 @@ public function testCleanup(): void // switch traffic - $secondEngine->run(new Run()); + $secondEngine->execute(new Run()); self::assertEquals( [ @@ -1345,7 +1345,7 @@ public function testCleanup(): void // shutdown second version (with cleanup) - $secondEngine->run(new TeardownCommand()); + $secondEngine->execute(new TeardownCommand()); self::assertEquals( [ @@ -1418,11 +1418,11 @@ public function testLookup(): void $subscriberRepository, ); - $result = $engine->run(new SetupCommand()); + $result = $engine->execute(new SetupCommand()); self::assertEquals([], $result->errors); - $result = $engine->run(new Boot()); + $result = $engine->execute(new Boot()); self::assertEquals(0, $result->processedMessages); self::assertEquals([], $result->errors); @@ -1431,7 +1431,7 @@ public function testLookup(): void $profile = Profile::create($profileId, 'John'); $repository->save($profile); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(1, $result->processedMessages); self::assertEquals([], $result->errors); @@ -1447,7 +1447,7 @@ public function testLookup(): void $profile->promoteToAdmin(); $repository->save($profile); - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertEquals(2, $result->processedMessages); self::assertEquals([], $result->errors); @@ -1499,7 +1499,7 @@ class { $subscriberRepository, ); - $engine->run(new SetupCommand()); + $engine->execute(new SetupCommand()); $subscriptions = $engine->subscriptions(); self::assertCount(1, $subscriptions); @@ -1520,7 +1520,7 @@ class { $newSubscriberRepository, ); - $engine->run(new Refresh()); + $engine->execute(new Refresh()); $subscriptions = $engine->subscriptions(); self::assertCount(1, $subscriptions); diff --git a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php index 5c767ba32..cd6bc2133 100644 --- a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php @@ -27,8 +27,8 @@ public function testRunFinished(): void $expectedResult = new ProcessedResult(0); $command = new Run(); - $parent->expects($this->once())->method('run')->with($command)->willReturn($expectedResult); - $result = $engine->run($command); + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + $result = $engine->execute($command); self::assertEquals($expectedResult, $result); } @@ -46,11 +46,11 @@ public function testRunSecondTime(): void new RuntimeException('baz'), ); - $parent->expects($this->exactly(2))->method('run')->with($command)->willReturn( + $parent->expects($this->exactly(2))->method('execute')->with($command)->willReturn( new ProcessedResult(1, true, [$error]), new ProcessedResult(0), ); - $result = $engine->run($command); + $result = $engine->execute($command); self::assertEquals(new ProcessedResult(1, false, [$error]), $result); } @@ -62,12 +62,12 @@ public function testRunLimit(): void $engine = new CatchUpSubscriptionEngine($parent, 2); $command = new Run(); - $parent->expects($this->exactly(2))->method('run')->with($command)->willReturn( + $parent->expects($this->exactly(2))->method('execute')->with($command)->willReturn( new ProcessedResult(1), new ProcessedResult(1), ); - $result = $engine->run($command); + $result = $engine->execute($command); self::assertEquals(new ProcessedResult(2), $result); } diff --git a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php index d244f6169..fea7915db 100644 --- a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php @@ -40,7 +40,7 @@ class { #[Subscribe(ProfileVisited::class)] public function handle(): void { - $this->engine?->run(new Boot()); + $this->engine?->execute(new Boot()); } }; @@ -62,7 +62,7 @@ public function handle(): void $subscriber->engine = $engine; - $result = $engine->run(new Boot()); + $result = $engine->execute(new Boot()); self::assertCount(1, $result->errors); self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); @@ -79,7 +79,7 @@ class { #[Subscribe(ProfileVisited::class)] public function handle(): void { - $this->engine?->run(new Run()); + $this->engine?->execute(new Run()); } }; @@ -101,7 +101,7 @@ public function handle(): void $subscriber->engine = $engine; - $result = $engine->run(new Run()); + $result = $engine->execute(new Run()); self::assertCount(1, $result->errors); self::assertInstanceOf(AlreadyProcessing::class, $result->errors[0]->throwable); diff --git a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php index b95500128..b42a6b616 100644 --- a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php @@ -28,8 +28,8 @@ public function testRunSuccess(): void $command = new Setup(); - $parent->expects($this->once())->method('run')->with($command)->willReturn($expectedResult); - $result = $engine->run($command); + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + $result = $engine->execute($command); self::assertSame($expectedResult, $result); } @@ -49,8 +49,8 @@ public function testRunError(): void new Error('id2', 'error2', new RuntimeException('error2')), ]); - $parent->expects($this->once())->method('run')->with($command)->willReturn($expectedResult); - $engine->run($command); + $parent->expects($this->once())->method('execute')->with($command)->willReturn($expectedResult); + $engine->execute($command); } public function testSubscriptions(): void diff --git a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php index e718c70ea..332054d96 100644 --- a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php +++ b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php @@ -82,7 +82,7 @@ public function testSave(): void $defaultRepository->expects($this->once())->method('save')->with($aggregate); $engine = $this->createMock(SubscriptionEngine::class); - $engine->expects($this->once())->method('run')->with($command)->willReturn(new ProcessedResult(21)); + $engine->expects($this->once())->method('execute')->with($command)->willReturn(new ProcessedResult(21)); $repository = new RunSubscriptionEngineRepository( $defaultRepository, @@ -112,7 +112,7 @@ public function testSaveWithAlreadyProcessing(): void $defaultRepository->expects($this->once())->method('save')->with($aggregate); $engine = $this->createMock(SubscriptionEngine::class); - $engine->expects($this->once())->method('run')->with($command)->willThrowException(new AlreadyProcessing()); + $engine->expects($this->once())->method('execute')->with($command)->willThrowException(new AlreadyProcessing()); $repository = new RunSubscriptionEngineRepository( $defaultRepository, From ef91727b51652320cb1c8bed9685aba28ad06d79 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 11 Jun 2026 13:20:38 +0200 Subject: [PATCH 10/12] update docs --- docs/UPGRADE-4.0.md | 54 +++++++++++++++++++ docs/cli.md | 2 + docs/getting-started.md | 3 +- docs/subscription.md | 116 ++++++++++++++++++++++++++++------------ 4 files changed, 139 insertions(+), 36 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 56ec77a5f..9efd02bf8 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -86,6 +86,60 @@ $subscriptionEngine = new DefaultSubscriptionEngine( RetryStrategyRepository::withDefault($retryStrategy), ); ``` +### Subscription Engine Commands + +The `SubscriptionEngine` interface has been changed. +The methods `setup`, `boot`, `run`, `teardown`, `remove`, `reactivate`, `pause` and `refresh` have been replaced +by a single `execute` method that takes a command object. +The `ids` and `groups` filters, previously passed via `SubscriptionEngineCriteria`, +are now constructor parameters of the command objects. +The `SubscriptionEngineCriteria` is now only used for the `subscriptions` method. + +before: + +```php +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; + +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->setup(new SubscriptionEngineCriteria(ids: ['profile_1']), skipBooting: true); +$subscriptionEngine->boot(new SubscriptionEngineCriteria(ids: ['profile_1']), limit: 100); +$subscriptionEngine->run(new SubscriptionEngineCriteria(ids: ['profile_1']), limit: 100); +$subscriptionEngine->teardown(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->remove(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->reactivate(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->pause(new SubscriptionEngineCriteria(ids: ['profile_1'])); +$subscriptionEngine->refresh(new SubscriptionEngineCriteria(ids: ['profile_1'])); +``` +after: + +```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; + +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->execute(new Setup(ids: ['profile_1'], skipBooting: true)); +$subscriptionEngine->execute(new Boot(ids: ['profile_1'], limit: 100)); +$subscriptionEngine->execute(new Run(ids: ['profile_1'], limit: 100)); +$subscriptionEngine->execute(new Teardown(ids: ['profile_1'])); +$subscriptionEngine->execute(new Remove(ids: ['profile_1'])); +$subscriptionEngine->execute(new Reactivate(ids: ['profile_1'])); +$subscriptionEngine->execute(new Pause(ids: ['profile_1'])); +$subscriptionEngine->execute(new Refresh(ids: ['profile_1'])); +``` +Further changes: + +* The `CanRefreshSubscriptions` interface has been removed. Refresh is now part of the `SubscriptionEngine` interface via the `Refresh` command. +* `ProcessedResult` now extends `Result`, so the `execute` method always returns a `Result`. The `Boot` and `Run` commands return a `ProcessedResult`. +* The `DefaultSubscriptionEngine` accepts an optional `EventDispatcherInterface` as last constructor argument to hook into the engine with own listeners. + ## Store ### StreamStore diff --git a/docs/cli.md b/docs/cli.md index c2f86d906..b1b4b6e0b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -34,6 +34,7 @@ To manage your subscriptions there are the following cli commands. * SubscriptionBootCommand: `event-sourcing:subscription:boot` * SubscriptionPauseCommand: `event-sourcing:subscription:pause` * SubscriptionReactiveCommand: `event-sourcing:subscription:reactive` +* SubscriptionRefreshCommand: `event-sourcing:subscription:refresh` * SubscriptionRemoveCommand: `event-sourcing:subscription:remove` * SubscriptionRunCommand: `event-sourcing:subscription:run` * SubscriptionSetupCommand: `event-sourcing:subscription:setup` @@ -86,6 +87,7 @@ $cli->addCommands([ new Command\SubscriptionTeardownCommand($subscriptionEngine), new Command\SubscriptionRemoveCommand($subscriptionEngine), new Command\SubscriptionReactivateCommand($subscriptionEngine), + new Command\SubscriptionRefreshCommand($subscriptionEngine), new Command\SubscriptionSetupCommand($subscriptionEngine), new Command\SubscriptionStatusCommand($subscriptionEngine), new Command\SchemaCreateCommand($schemaDirector), diff --git a/docs/getting-started.md b/docs/getting-started.md index fd8e6ea65..baa3819c4 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -339,6 +339,7 @@ use Doctrine\DBAL\Connection; use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; @@ -358,7 +359,7 @@ $schemaDirector = new DoctrineSchemaDirector( $schemaDirector->create(); /** @var SubscriptionEngine $engine */ -$engine->setup(skipBooting: true); +$engine->execute(new Setup(skipBooting: true)); ``` :::note diff --git a/docs/subscription.md b/docs/subscription.md index 8b719fc6f..b9c367d87 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1142,7 +1142,8 @@ $subscriberAccessorRepository = new MetadataSubscriberAccessorRepository([ Now we can create the subscription engine and plug together the necessary services. The message loader is needed to load the messages, the Subscription Store to store the subscription state and we need the subscriber accessor repository. Optionally, we can also pass a retry strategy. -Finally, if we want to use the cleanup feature, we need to pass the cleanup handlers. +If we want to use the cleanup feature, we need to pass the cleanup handlers. +Finally, we can pass an event dispatcher to hook into the engine with own listeners. ```php use Doctrine\DBAL\Connection; @@ -1153,6 +1154,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; +use Symfony\Component\EventDispatcher\EventDispatcher; /** * @var MessageLoader $messageLoader @@ -1169,6 +1171,34 @@ $subscriptionEngine = new DefaultSubscriptionEngine( $retryStrategyRepository, // optional, if not set the default retry strategy is used $logger, // optional new DefaultCleaner([new DbalCleanupTaskHandler($projectionConnection)]), // optional but required if you want to use the cleanup feature + new EventDispatcher(), // optional, to hook into the engine with own listeners +); +``` +### Engine Events + +The `DefaultSubscriptionEngine` dispatches events during processing on the passed event dispatcher. +You can register your own listeners to hook into the engine, for example for logging, metrics or batching. + +| Event | Description | +|--------------------------|----------------------------------------------------------------------| +| `OnCommand` | A command was passed to the engine for execution | +| `OnSubscriptions` | The engine determined the subscriptions for the current command | +| `OnHandleMessage` | A message is about to be passed to a subscriber | +| `OnHandleMessageSuccess` | A message was successfully handled by a subscriber | +| `OnHandleMessageError` | An error occurred while a subscriber was handling a message | +| `OnProcessingFinished` | The engine finished processing the stream (ended or limit reached) | +| `OnResult` | The engine finished the command and returns the result | + +```php +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; +use Symfony\Component\EventDispatcher\EventDispatcher; + +$eventDispatcher = new EventDispatcher(); +$eventDispatcher->addListener( + OnHandleMessageError::class, + static function (OnHandleMessageError $event): void { + // own error handling like logging or metrics + }, ); ``` ### Catch up Subscription Engine @@ -1249,15 +1279,20 @@ Especially in combination with the `CatchUpSubscriptionEngine` and `ThrowOnError ## Usage -The Subscription Engine has a few methods needed to use it effectively. -A `SubscriptionEngineCriteria` can be passed to all of these methods to filter the respective subscriptions. +The Subscription Engine is controlled with command objects. +Each command is passed to the `execute` method, which returns a `Result` with the errors that occurred. +Every command accepts `ids` and `groups` parameters to filter the subscriptions the command should be applied to. ```php -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -$criteria = new SubscriptionEngineCriteria( - ids: ['profile_1', 'welcome_email'], - groups: ['default'], +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->execute( + new Run( + ids: ['profile_1', 'welcome_email'], + groups: ['default'], + ), ); ``` @@ -1272,52 +1307,62 @@ In this step, the subscription engine also tries to call the `setup` method if a After the setup process, the subscription is set to booting or active. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->setup(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Setup()); ``` :::tip -You can skip the booting step with the second boolean parameter named `skipBooting`. +You can skip the booting step with the `skipBooting` parameter: `new Setup(skipBooting: true)`. ::: ### Boot -You can boot the subscriptions with the `boot` method. +You can boot the subscriptions with the `Boot` command. All booting subscriptions will catch up to the current event stream. After the boot process, the subscription is set to active or finished. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->boot(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Boot()); ``` + +:::tip +You can limit the number of processed messages with the `limit` parameter: `new Boot(limit: 100)`. +::: + ### Run All active subscriptions are continued and updated here. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->execute(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Run()); ``` + +:::tip +You can limit the number of processed messages with the `limit` parameter: `new Run(limit: 100)`. +::: + ### Teardown If subscriptions are detached, they can be cleaned up here. The subscription engine also tries to call the `teardown` method if available. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Teardown; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->teardown(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Teardown()); ``` ### Remove @@ -1326,11 +1371,11 @@ An attempt is made to call the `teardown` method if available. But the entry will still be removed if it doesn't work. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->remove(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Remove()); ``` ### Reactivate @@ -1338,11 +1383,11 @@ If a subscription had an error or is outdated, you can reactivate it. As a result, the subscription gets in the last status again. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->reactivate(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Reactivate()); ``` ### Pause @@ -1351,38 +1396,39 @@ The subscription will then no longer be managed by the subscription engine. You can reactivate the subscription if you want so that it continues. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->pause(new SubscriptionEngineCriteria()); +$subscriptionEngine->execute(new Pause()); ``` -### Status +### Refresh -To get the current status of all subscriptions, you can get them using the `subscriptions` method. +If you change the metadata of a subscriber in the code (e.g. `runMode`, `group` or `cleanupTasks`), +you can use the `Refresh` command to update the existing subscriptions in the store. ```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptions = $subscriptionEngine->subscriptions(new SubscriptionEngineCriteria()); - -foreach ($subscriptions as $subscription) { - echo $subscription->status()->value; -} +$subscriptionEngine->execute(new Refresh()); ``` -### Refresh +### Status -If you change the metadata of a subscriber in the code (e.g. `runMode`, `group` or `cleanupTasks`), -you can use the `refresh` method to update the existing subscriptions in the store. +To get the current status of all subscriptions, you can get them using the `subscriptions` method. +A `SubscriptionEngineCriteria` can be passed to filter the subscriptions. ```php use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; /** @var SubscriptionEngine $subscriptionEngine */ -$subscriptionEngine->refresh(new SubscriptionEngineCriteria()); +$subscriptions = $subscriptionEngine->subscriptions(new SubscriptionEngineCriteria()); + +foreach ($subscriptions as $subscription) { + echo $subscription->status()->value; +} ``` ## Learn more From 7ac6991947621ca01dcddfde31346f5e8323a343 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 11 Jun 2026 13:28:47 +0200 Subject: [PATCH 11/12] fix benchmarks --- tests/Benchmark/CommandToQueryBench.php | 3 ++- tests/Benchmark/SubscriptionEngineBatchBench.php | 9 ++++++--- tests/Benchmark/SubscriptionEngineBench.php | 9 ++++++--- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/tests/Benchmark/CommandToQueryBench.php b/tests/Benchmark/CommandToQueryBench.php index e6695a4e1..43f1f5893 100644 --- a/tests/Benchmark/CommandToQueryBench.php +++ b/tests/Benchmark/CommandToQueryBench.php @@ -18,6 +18,7 @@ use Patchlevel\EventSourcing\Snapshot\Adapter\InMemorySnapshotAdapter; use Patchlevel\EventSourcing\Snapshot\DefaultSnapshotStore; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepositoryManager; @@ -89,7 +90,7 @@ public function setUp(): void $schemaDirector = new DoctrineSchemaDirector($connection, $store); $schemaDirector->create(); - $engine->setup(skipBooting: true); + $engine->execute(new Setup(skipBooting: true)); $this->updateId = ProfileId::generate(); $this->commandBus->dispatch(new CreateProfile($this->updateId, 'Peter')); diff --git a/tests/Benchmark/SubscriptionEngineBatchBench.php b/tests/Benchmark/SubscriptionEngineBatchBench.php index f610ce9b2..39d2e5bfa 100644 --- a/tests/Benchmark/SubscriptionEngineBatchBench.php +++ b/tests/Benchmark/SubscriptionEngineBatchBench.php @@ -12,6 +12,9 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; @@ -84,8 +87,8 @@ public function setUp(): void #[Bench\Revs(10)] public function benchHandle10000Events(): void { - $this->subscriptionEngine->setup(); - $this->subscriptionEngine->boot(); - $this->subscriptionEngine->remove(); + $this->subscriptionEngine->execute(new Setup()); + $this->subscriptionEngine->execute(new Boot()); + $this->subscriptionEngine->execute(new Remove()); } } diff --git a/tests/Benchmark/SubscriptionEngineBench.php b/tests/Benchmark/SubscriptionEngineBench.php index 70783e7e8..6ec636e0e 100644 --- a/tests/Benchmark/SubscriptionEngineBench.php +++ b/tests/Benchmark/SubscriptionEngineBench.php @@ -13,6 +13,9 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Remove; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; @@ -92,8 +95,8 @@ public function setUp(): void #[Bench\Revs(10)] public function benchHandle10000Events(): void { - $this->subscriptionEngine->setup(); - $this->subscriptionEngine->boot(); - $this->subscriptionEngine->remove(); + $this->subscriptionEngine->execute(new Setup()); + $this->subscriptionEngine->execute(new Boot()); + $this->subscriptionEngine->execute(new Remove()); } } From a9eb109f8cbcfbf0654bd7c7081aa0a6268484a4 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 11 Jun 2026 13:40:28 +0200 Subject: [PATCH 12/12] fix cs --- phpstan-baseline.neon | 14 +---- .../RunSubscriptionEngineRepository.php | 2 +- .../Subscription/SubscriptionTest.php | 49 +++++++++-------- .../Engine/Handler/BootHandlerTest.php | 2 +- .../Engine/Handler/ReactivateHandlerTest.php | 2 +- .../Engine/Handler/RefreshHandlerTest.php | 2 +- .../Engine/Handler/RemoveHandlerTest.php | 2 +- .../Engine/Handler/RunHandlerTest.php | 6 ++- .../Engine/Handler/SetupHandlerTest.php | 2 +- .../Engine/Handler/TeardownHandlerTest.php | 2 +- .../Engine/Listener/BatchSubscriberTest.php | 54 +++++++++++++------ .../Engine/Listener/DetachListenerTest.php | 2 +- .../Listener/DiscoverSubscriberTest.php | 2 +- .../Engine/Listener/FailSubscriberTest.php | 2 +- .../Engine/Listener/RetrySubscriberTest.php | 3 +- .../Engine/NextSubscriptionEngineTest.php | 2 +- .../RunSubscriptionEngineRepositoryTest.php | 1 - 17 files changed, 85 insertions(+), 64 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1c4cd5287..8db04aa22 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -106,7 +106,7 @@ parameters: message: '#^Parameter \#1 \$command of callable Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\BootHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\PauseHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\ReactivateHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RefreshHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RemoveHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\RunHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\SetupHandler\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Handler\\TeardownHandler expects Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Boot\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Pause\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Reactivate\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Refresh\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Remove\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Run\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Setup\|Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Teardown, Patchlevel\\EventSourcing\\Subscription\\Engine\\Command\\Command given\.$#' identifier: argument.type count: 1 - path: src/Subscription/Engine/NextSubscriptionEngine.php + path: src/Subscription/Engine/DefaultSubscriptionEngine.php - message: '#^Parameter \#1 \$eventClass of method Patchlevel\\EventSourcing\\Metadata\\Event\\EventRegistry\:\:eventName\(\) expects class\-string, string given\.$#' @@ -402,18 +402,6 @@ parameters: count: 2 path: tests/Unit/QueryBus/ServiceHandlerProviderTest.php - - - message: '#^Match expression does not handle remaining value\: string$#' - identifier: match.unhandled - count: 1 - path: tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php - - - - message: '#^Parameter \#1 \$error of static method Patchlevel\\EventSourcing\\Subscription\\ThrowableToErrorContextTransformer\:\:transform\(\) expects Throwable, Throwable\|null given\.$#' - identifier: argument.type - count: 8 - path: tests/Unit/Subscription/Engine/DefaultSubscriptionEngineTest.php - - message: '#^Offset ''args'' on array\{file\: literal\-string&non\-falsy\-string, line\: int, function\: ''createException'', class\: ''Patchlevel\\\\EventSourcing\\\\Tests\\\\Unit\\\\Subscription\\\\ErrorContextTest'', type\: ''\-\>'', args\: array\\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset diff --git a/src/Subscription/Repository/RunSubscriptionEngineRepository.php b/src/Subscription/Repository/RunSubscriptionEngineRepository.php index 059680472..fc66fde67 100644 --- a/src/Subscription/Repository/RunSubscriptionEngineRepository.php +++ b/src/Subscription/Repository/RunSubscriptionEngineRepository.php @@ -18,7 +18,7 @@ final class RunSubscriptionEngineRepository implements Repository { /** - * @param Repository $repository + * @param Repository $repository * @param list|null $ids * @param list|null $groups * @param positive-int|null $limit diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index b1e9586a9..6da6843d2 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -35,8 +35,9 @@ use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; +use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -55,7 +56,6 @@ use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProjectionWithCleanup; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; -use Psl\Collection\Set; use RuntimeException; use function gc_collect_cycles; @@ -137,7 +137,7 @@ public function testHappyPath(): void $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -159,7 +159,7 @@ public function testHappyPath(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -263,7 +263,7 @@ public function testGapResolver(): void $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -285,7 +285,7 @@ public function testGapResolver(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); self::assertEquals( @@ -382,7 +382,7 @@ public function testErrorHandling(): void self::assertEquals([], $result->errors); $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -402,7 +402,7 @@ public function testErrorHandling(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -421,7 +421,7 @@ public function testErrorHandling(): void $result = $engine->execute(new Run()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -436,7 +436,7 @@ public function testErrorHandling(): void $clock->sleep(5); $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -456,7 +456,7 @@ public function testErrorHandling(): void $clock->sleep(10); $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -476,7 +476,7 @@ public function testErrorHandling(): void $clock->sleep(20); $result = $engine->execute(new Run()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -502,7 +502,7 @@ public function testErrorHandling(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -524,7 +524,7 @@ public function testErrorHandling(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -595,7 +595,7 @@ public function testSelfRecovery(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -619,7 +619,7 @@ public function testSelfRecovery(): void $subscriber->onFailedError = true; $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -704,7 +704,7 @@ public function subscribe(): void self::assertEquals([], $result->errors); $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $subscription = self::findSubscription($engine->subscriptions(), 'error_producer'); @@ -722,7 +722,7 @@ public function subscribe(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertCount(1, $result->errors); $error = $result->errors[0]; @@ -1424,7 +1424,7 @@ public function testLookup(): void $result = $engine->execute(new Boot()); - self::assertEquals(0, $result->processedMessages); + self::assertProcessedMessages(0, $result); self::assertEquals([], $result->errors); $profileId = ProfileId::generate(); @@ -1433,7 +1433,7 @@ public function testLookup(): void $result = $engine->execute(new Run()); - self::assertEquals(1, $result->processedMessages); + self::assertProcessedMessages(1, $result); self::assertEquals([], $result->errors); $result = $this->projectionConnection->fetchAssociative( @@ -1449,7 +1449,7 @@ public function testLookup(): void $result = $engine->execute(new Run()); - self::assertEquals(2, $result->processedMessages); + self::assertProcessedMessages(2, $result); self::assertEquals([], $result->errors); $result = $this->projectionConnection->fetchAssociative( @@ -1529,6 +1529,13 @@ class { self::assertEquals(RunMode::FromNow, $subscriptions[0]->runMode()); } + /** @phpstan-assert ProcessedResult $result */ + private static function assertProcessedMessages(int $expected, Result $result): void + { + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertSame($expected, $result->processedMessages); + } + /** @param list $subscriptions */ private static function findSubscription(array $subscriptions, string $id): Subscription { diff --git a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php index 061c75c42..2a9987e35 100644 --- a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php @@ -39,7 +39,7 @@ #[CoversClass(BootHandler::class)] final class BootHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, diff --git a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php index 19a0a2387..3b5ebb3f0 100644 --- a/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/ReactivateHandlerTest.php @@ -21,7 +21,7 @@ #[CoversClass(ReactivateHandler::class)] final class ReactivateHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createHandler(DummySubscriptionStore $store, array $subscribers = []): ReactivateHandler { return new ReactivateHandler( diff --git a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php index 5680f5118..038ca2dee 100644 --- a/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RefreshHandlerTest.php @@ -22,7 +22,7 @@ #[CoversClass(RefreshHandler::class)] final class RefreshHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createHandler(DummySubscriptionStore $store, array $subscribers = []): RefreshHandler { return new RefreshHandler( diff --git a/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php index c2b9189e7..f70eb96d0 100644 --- a/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RemoveHandlerTest.php @@ -28,7 +28,7 @@ #[CoversClass(RemoveHandler::class)] final class RemoveHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createHandler( DummySubscriptionStore $store, array $subscribers = [], diff --git a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php index 5f53971f0..f7dd5b4e7 100644 --- a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php @@ -40,7 +40,11 @@ #[CoversClass(RunHandler::class)] final class RunHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** + * @param list $subscribers + * + * @return array{RunHandler, EventDispatcher, RunCommand} + */ private function createHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, diff --git a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php index a80158522..8a59e3cf1 100644 --- a/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/SetupHandlerTest.php @@ -30,7 +30,7 @@ #[CoversClass(SetupHandler::class)] final class SetupHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, diff --git a/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php index 32bad29b2..880864f26 100644 --- a/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/TeardownHandlerTest.php @@ -28,7 +28,7 @@ #[CoversClass(TeardownHandler::class)] final class TeardownHandlerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createHandler( DummySubscriptionStore $store, array $subscribers = [], diff --git a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php index 1a30188bf..2082c6841 100644 --- a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php @@ -40,6 +40,7 @@ #[CoversClass(BatchSubscriber::class)] final class BatchSubscriberTest extends TestCase { + /** @param list $subscribers */ private function createBootHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, @@ -63,6 +64,11 @@ private function createBootHandler( return new BootHandler($messageLoader, $subscriptionManager, $subscriberRepository, $messageProcessor, $eventDispatcher, new NullLogger()); } + /** + * @param list $subscribers + * + * @return array{RunHandler, EventDispatcher, RunCommand} + */ private function createRunHandler( MessageLoader $messageLoader, DummySubscriptionStore $store, @@ -181,8 +187,10 @@ public function testBootBatchingSuccessForceCommit(): void public function testBootBatchingWithHandleError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), + throwForMessage: $exception, ); $store = new DummySubscriptionStore([ @@ -220,7 +228,7 @@ public function testBootBatchingWithHandleError(): void new SubscriptionError( 'ERROR', Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -233,8 +241,10 @@ public function testBootBatchingWithHandleError(): void public function testBootBatchingWithBeginBatchError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForBeginBatch: new RuntimeException('ERROR'), + throwForBeginBatch: $exception, ); $store = new DummySubscriptionStore([ @@ -272,7 +282,7 @@ public function testBootBatchingWithBeginBatchError(): void new SubscriptionError( 'ERROR', Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -285,8 +295,10 @@ public function testBootBatchingWithBeginBatchError(): void public function testBootBatchingWithCommitBatchError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForCommitBatch: new RuntimeException('ERROR'), + throwForCommitBatch: $exception, ); $store = new DummySubscriptionStore([ @@ -324,7 +336,7 @@ public function testBootBatchingWithCommitBatchError(): void new SubscriptionError( 'ERROR', Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -337,8 +349,10 @@ public function testBootBatchingWithCommitBatchError(): void public function testBootBatchingWithRollbackBatchError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), + throwForMessage: $exception, throwForRollbackBatch: new RuntimeException('ERROR'), ); @@ -377,7 +391,7 @@ public function testBootBatchingWithRollbackBatchError(): void new SubscriptionError( 'ERROR', Status::Booting, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -482,8 +496,10 @@ public function testRunningBatchingSuccessForceCommit(): void public function testRunningBatchingWithHandleError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), + throwForMessage: $exception, ); $store = new DummySubscriptionStore([ @@ -522,7 +538,7 @@ public function testRunningBatchingWithHandleError(): void new SubscriptionError( 'ERROR', Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -535,8 +551,10 @@ public function testRunningBatchingWithHandleError(): void public function testRunningBatchingWithBeginBatchError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForBeginBatch: new RuntimeException('ERROR'), + throwForBeginBatch: $exception, ); $store = new DummySubscriptionStore([ @@ -575,7 +593,7 @@ public function testRunningBatchingWithBeginBatchError(): void new SubscriptionError( 'ERROR', Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForBeginBatch), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -588,8 +606,10 @@ public function testRunningBatchingWithBeginBatchError(): void public function testRunningBatchingWithCommitBatchError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForCommitBatch: new RuntimeException('ERROR'), + throwForCommitBatch: $exception, ); $store = new DummySubscriptionStore([ @@ -628,7 +648,7 @@ public function testRunningBatchingWithCommitBatchError(): void new SubscriptionError( 'ERROR', Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForCommitBatch), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); @@ -641,8 +661,10 @@ public function testRunningBatchingWithCommitBatchError(): void public function testRunningBatchingWithRollbackBatchError(): void { + $exception = new RuntimeException('ERROR'); + $subscriber = new BatchingSubscriber( - throwForMessage: new RuntimeException('ERROR'), + throwForMessage: $exception, throwForRollbackBatch: new RuntimeException('ERROR'), ); @@ -682,7 +704,7 @@ public function testRunningBatchingWithRollbackBatchError(): void new SubscriptionError( 'ERROR', Status::Active, - ThrowableToErrorContextTransformer::transform($subscriber->throwForMessage), + ThrowableToErrorContextTransformer::transform($exception), ), ), ); diff --git a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php index 30fe97106..dcd86abc2 100644 --- a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php @@ -22,7 +22,7 @@ #[CoversClass(DetachListener::class)] final class DetachListenerTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createListener(DummySubscriptionStore $store, array $subscribers = []): DetachListener { return new DetachListener( diff --git a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php index 072016b1b..38d4874b6 100644 --- a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php @@ -31,7 +31,7 @@ #[CoversClass(DiscoverSubscriber::class)] final class DiscoverSubscriberTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createListener(DummySubscriptionStore $store, array $subscribers = []): DiscoverSubscriber { return new DiscoverSubscriber( diff --git a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php index d889bab47..0cca7387f 100644 --- a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php @@ -31,7 +31,7 @@ final class FailSubscriberTest extends TestCase { /** - * @param iterable $subscribers + * @param list $subscribers * * @return array{FailSubscriber, SubscriptionManager} */ diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php index 223751142..edb3dceb2 100644 --- a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -28,7 +28,7 @@ #[CoversClass(RetrySubscriber::class)] final class RetrySubscriberTest extends TestCase { - /** @param iterable $subscribers */ + /** @param list $subscribers */ private function createListener( DummySubscriptionStore $store, array $subscribers, @@ -75,6 +75,7 @@ class { self::assertNull($updated->subscriptionError()); } + /** @param 'setup'|'boot'|'run' $method */ #[DataProvider('statusProvider')] public function testShouldNotRetryOtherStatus(string $method, string $status): void { diff --git a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php index fea7915db..625bfc83a 100644 --- a/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/NextSubscriptionEngineTest.php @@ -11,8 +11,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\AlreadyProcessing; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; -use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; diff --git a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php index 332054d96..8ab58ff7a 100644 --- a/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php +++ b/tests/Unit/Subscription/Repository/RunSubscriptionEngineRepositoryTest.php @@ -9,7 +9,6 @@ use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepository; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Email; use Patchlevel\EventSourcing\Tests\Unit\Fixture\Profile;