From 0bd3078650213f66b00d5894bb561b9fd2197ccf Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 14 Oct 2026 21:29:00 +0200 Subject: [PATCH 01/57] Add subscription partitions with a routing inbox A subscriber with a #[Partition] method is split into one subscription per partition key. The main subscription resolves the key of every message once and writes the message index into the inbox of that partition, creating the partition on the fly. Partitions only process the messages in their inbox and are only claimed by a worker if the inbox has work, so idle partitions cost nothing and a changed or non-deterministic key can not lose messages. Engine command ids now also match the subscriber id, so commands for a partitioned subscriber apply to all its partitions, and a single partition can be targeted by its own id. --- docs/UPGRADE-4.0.md | 23 + docs/cli.md | 7 + docs/subscription.md | 60 +++ phpstan-baseline.neon | 6 - src/Attribute/Partition.php | 15 + src/Console/Command/SubscriptionCommand.php | 9 +- .../Command/SubscriptionStatusCommand.php | 115 ++++- .../AttributeSubscriberMetadataFactory.php | 40 ++ .../Subscriber/DuplicatePartitionMethod.php | 25 ++ .../Subscriber/InvalidPartitionMethod.php | 40 ++ .../Subscriber/SubscriberMetadata.php | 1 + .../Engine/DefaultSubscriptionEngine.php | 9 + .../Engine/Handler/RefreshHandler.php | 3 +- .../Engine/Handler/RemoveHandler.php | 1 + .../Engine/Handler/RunHandler.php | 1 + .../Engine/Handler/SetupHandler.php | 1 + .../Engine/Handler/TeardownHandler.php | 1 + .../Engine/InvalidPartitionKey.php | 40 ++ .../Engine/Listener/BatchSubscriber.php | 3 +- .../Engine/Listener/DiscoverSubscriber.php | 2 +- .../Listener/RemovePartitionsListener.php | 62 +++ src/Subscription/Engine/MessageProcessor.php | 150 +++---- src/Subscription/Engine/PartitionInbox.php | 51 +++ src/Subscription/Engine/PartitionRouter.php | 109 +++++ .../Engine/SubscriptionManager.php | 39 ++ .../Engine/SubscriptionRunner.php | 251 +++++++++-- src/Subscription/InvalidPartitionId.php | 23 + .../PartitionOfPartitionNotPossible.php | 17 + .../Store/DoctrineSubscriptionStore.php | 115 ++++- .../Store/InMemorySubscriptionStore.php | 64 ++- .../Store/SubscriptionCriteria.php | 7 +- src/Subscription/Store/SubscriptionStore.php | 26 +- .../Subscriber/MetadataSubscriberAccessor.php | 12 + src/Subscription/Subscription.php | 55 ++- tests/Architecture/LayerDependenciesTest.php | 1 + .../DoctrineSubscriptionStoreTest.php | 104 +++++ .../PartitionedSubscriptionTest.php | 394 ++++++++++++++++++ .../PartitionedBatchProfileProjection.php | 69 +++ .../PartitionedProfileProjection.php | 68 +++ ...AttributeSubscriberMetadataFactoryTest.php | 107 +++++ .../Subscription/DummySubscriptionStore.php | 21 + .../Store/DoctrineSubscriptionStoreTest.php | 41 +- .../Store/InMemorySubscriptionStoreTest.php | 94 +++++ tests/Unit/Subscription/SubscriptionTest.php | 44 ++ 44 files changed, 2167 insertions(+), 159 deletions(-) create mode 100644 src/Attribute/Partition.php create mode 100644 src/Metadata/Subscriber/DuplicatePartitionMethod.php create mode 100644 src/Metadata/Subscriber/InvalidPartitionMethod.php create mode 100644 src/Subscription/Engine/InvalidPartitionKey.php create mode 100644 src/Subscription/Engine/Listener/RemovePartitionsListener.php create mode 100644 src/Subscription/Engine/PartitionInbox.php create mode 100644 src/Subscription/Engine/PartitionRouter.php create mode 100644 src/Subscription/InvalidPartitionId.php create mode 100644 src/Subscription/PartitionOfPartitionNotPossible.php create mode 100644 tests/Integration/Subscription/PartitionedSubscriptionTest.php create mode 100644 tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php create mode 100644 tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index b288215d6..7f8c762d9 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -470,6 +470,29 @@ are logged and retried on the next pass without landing in `result.errors`. Any that single subscription into status `Error` and surfaces in `result.errors`, while the remaining subscriptions keep processing. +#### Subscription partitions + +Subscribers can be split into partitions with `#[Partition]`, see the subscription docs. +The subscription table gets the columns `subscriber_id` and `partition_key`, and there is a new table +`subscriptions_inbox` (or `{table name}_inbox`). Since `subscriber_id` is not nullable, fill it before +making it `NOT NULL`, otherwise the migration fails on existing rows: + +```sql +ALTER TABLE subscriptions ADD subscriber_id VARCHAR(255) DEFAULT NULL; +ALTER TABLE subscriptions ADD partition_key VARCHAR(255) DEFAULT NULL; +UPDATE subscriptions SET subscriber_id = id; +ALTER TABLE subscriptions ALTER COLUMN subscriber_id SET NOT NULL; +CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id); +CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); +``` +Adjust the table names if you configured your own, and the `ALTER COLUMN` syntax for MySQL (`MODIFY`). + +A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()`, implement the inbox methods +`addToInbox()`, `inbox()` and `removeFromInbox()`, remove the inbox in `remove()`, and support the new +`partitions` and `pendingPartitionsOnly` criteria. The `ids` of `SubscriptionCriteria` now match the subscription +id or the subscriber id, so the id of a partitioned subscriber also selects its partitions. +For subscribers without partitions nothing changes, because the subscriber id is the subscription id. + #### No global ordering across subscriptions Subscriptions are processed independently, so there is no global ordering of messages across diff --git a/docs/cli.md b/docs/cli.md index 14741234f..d8c00c416 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -238,6 +238,13 @@ If the subscription has an error, the command also prints the error message and ```bash bin/console event-sourcing:subscription:status profile_1 ``` +For a partitioned subscriber, the table shows a summary of its partitions. +Use `--partitions` to list them, and pass the id of a partition to show its details. + +```bash +bin/console event-sourcing:subscription:status --partitions +bin/console event-sourcing:subscription:status 'profile_1#tenant-a' +``` ### Pause The `event-sourcing:subscription:pause` command pauses subscriptions. diff --git a/docs/subscription.md b/docs/subscription.md index d2388bf9e..c50e3af80 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -848,6 +848,63 @@ Use `#[BatchFlush(afterMessages: ...)]` or a `#[BatchShouldFlush]` method to pre This allows you to decide when it's suitable to process the data and then release the memory. ::: +### Partitioning + +A subscriber is normally a single subscription: one worker processes it at a time, and an error stops it completely. +With `#[Partition]` the subscriber is split into one subscription per partition key. +The partitions are processed independently, so several workers can process them in parallel, +and an error only stops the partition it happened in. + +The method marked with `#[Partition]` gets the `Message` and returns the partition key. +Choose a key from your domain that groups everything that has to be processed in order, like the tenant or the aggregate. + +```php +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Message\Message; + +#[Projector('profile_1')] +final class ProfileProjector +{ + #[Partition] + public function partition(Message $message): string + { + return $message->header(TenantHeader::class)->tenant; + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + // ... + } +} +``` +The subscription with the subscriber id is the main subscription. It never calls your subscribe methods. +Instead, it resolves the partition key of every message exactly once and puts the message into the inbox of that partition. +Partitions are created on the fly as new keys appear, get the id `{subscriber id}#{partition key}` +and start with the group, run mode and status of the main subscription. +A partition only processes the messages in its inbox and is only claimed by a worker if its inbox has messages, +so idle partitions cost nothing. + +Setup, teardown and cleanup belong to the subscriber as a whole and only run on the main subscription. +Removing the main subscription removes its partitions as well. + +:::warning +The order of messages is only guaranteed within a partition, never across partitions. +Give all events of an aggregate the same key if your projection relies on their order. +::: + +:::note +The partition key must be a non-empty string and the partition id must not exceed 255 bytes. +The id of a partitioned subscriber must not contain `#`. +::: + +:::tip +Changing the partition method is safe: messages that were already routed stay in their partition. +Only the order between the old and the new partition of the same aggregate is not guaranteed during the change. +::: + ## Subscription Engine The subscription engine manages individual subscribers and keeps the subscriptions running. @@ -1560,6 +1617,7 @@ $subscriptionEngine->execute(new Run(limit: 100)); ``` The per-subscription order is always preserved. There is no global ordering across different subscriptions, but since subscriptions are independent of each other this does not matter. +To process a single subscriber in parallel, split it into [partitions](#partitioning). :::note This requires a database that supports `SKIP LOCKED` (PostgreSQL and MySQL do). SQLite serializes @@ -1644,6 +1702,8 @@ foreach ($subscriptions as $subscription) { echo $subscription->status()->value; } ``` +The ids of the criteria match the subscription id or the subscriber id, +so a partitioned subscriber includes all its partitions. ## Learn more * [How to use CLI commands](cli.md) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 6343c3cd3..01c7f0781 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -666,12 +666,6 @@ parameters: count: 1 path: tests/Unit/Message/Translator/ReplaceEventTranslatorTest.php - - - message: '#^Method class@anonymous/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest\.php\:284\:\:profileVisited\(\) has parameter \$message with no type specified\.$#' - identifier: missingType.parameter - count: 1 - path: tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php - - message: '#^Cannot access offset 0 on iterable\\.$#' identifier: offsetAccess.nonOffsetAccessible diff --git a/src/Attribute/Partition.php b/src/Attribute/Partition.php new file mode 100644 index 000000000..2b5fc085c --- /dev/null +++ b/src/Attribute/Partition.php @@ -0,0 +1,15 @@ +engine->subscriptions($criteria); + // a subscriber id includes all partitions, so the list stays as short as the number of subscribers return new SubscriptionEngineCriteria( - array_map( - static fn ($subscription) => $subscription->id(), + array_values(array_unique(array_map( + static fn ($subscription) => $subscription->subscriberId(), $subscriptions, - ), + ))), ); } } diff --git a/src/Console/Command/SubscriptionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php index 648de136a..e128652b0 100644 --- a/src/Console/Command/SubscriptionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -13,9 +13,11 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use function array_map; +use function count; +use function implode; use function is_array; use function sprintf; @@ -30,11 +32,18 @@ protected function configure(): void { parent::configure(); - $this->addArgument( - 'id', - InputArgument::OPTIONAL, - 'The subscription to display more information about', - ); + $this + ->addArgument( + 'id', + InputArgument::OPTIONAL, + 'The subscription to display more information about, a partition has the id "{subscriber id}#{partition key}"', + ) + ->addOption( + 'partitions', + null, + InputOption::VALUE_NONE, + 'list the partitions of partitioned subscribers', + ); } protected function execute(InputInterface $input, OutputInterface $output): int @@ -45,6 +54,52 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($id === null) { $subscriptions = $this->engine->subscriptions($this->subscriptionEngineCriteria($input)); + $showPartitions = InputHelper::bool($input->getOption('partitions')); + + /** @var array> $partitions */ + $partitions = []; + + foreach ($subscriptions as $subscription) { + if (!$subscription->isPartition()) { + continue; + } + + $partitions[$subscription->subscriberId()][] = $subscription; + } + + $rows = []; + + foreach ($subscriptions as $subscription) { + if ($subscription->isPartition()) { + continue; + } + + $rows[] = [ + $subscription->id(), + $subscription->group(), + $subscription->runMode()->value, + $subscription->position(), + $subscription->status()->value, + self::partitionSummary($partitions[$subscription->id()] ?? []), + $subscription->subscriptionError()?->errorMessage, + ]; + + if (!$showPartitions) { + continue; + } + + foreach ($partitions[$subscription->id()] ?? [] as $partition) { + $rows[] = [ + ' └ ' . $partition->partitionKey(), + $partition->group(), + $partition->runMode()->value, + $partition->position(), + $partition->status()->value, + null, + $partition->subscriptionError()?->errorMessage, + ]; + } + } $io->table( [ @@ -53,19 +108,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'run mode', 'position', 'status', + 'partitions', 'error message', ], - array_map( - static fn (Subscription $subscription) => [ - $subscription->id(), - $subscription->group(), - $subscription->runMode()->value, - $subscription->position(), - $subscription->status()->value, - $subscription->subscriptionError()?->errorMessage, - ], - $subscriptions, - ), + $rows, ); return 0; @@ -118,6 +164,41 @@ private function displayError(OutputStyle $io, array $context): void } } + /** + * The number of partitions and how many of them are not active, e.g. "12 (error: 1)". + * + * @param list $partitions + */ + private static function partitionSummary(array $partitions): string|null + { + if ($partitions === []) { + return null; + } + + $notActive = []; + + foreach ($partitions as $partition) { + if ($partition->isActive()) { + continue; + } + + $status = $partition->status()->value; + $notActive[$status] = ($notActive[$status] ?? 0) + 1; + } + + if ($notActive === []) { + return (string)count($partitions); + } + + $parts = []; + + foreach ($notActive as $status => $count) { + $parts[] = sprintf('%s: %d', $status, $count); + } + + return sprintf('%d (%s)', count($partitions), implode(', ', $parts)); + } + /** @param list $subscriptions */ private function findSubscription(array $subscriptions, string $id): Subscription { diff --git a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php index cf558df61..f7089194c 100644 --- a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php @@ -13,19 +13,24 @@ use Patchlevel\EventSourcing\Attribute\DisableEventEmitting; use Patchlevel\EventSourcing\Attribute\EnableEventEmittingDuringBoot; use Patchlevel\EventSourcing\Attribute\OnFailed; +use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\RetryStrategy; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Subscription; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; +use ReflectionNamedType; use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; use function array_key_exists; use function array_map; use function count; +use function str_contains; final class AttributeSubscriberMetadataFactory implements SubscriberMetadataFactory { @@ -68,6 +73,7 @@ public function metadata(string $subscriber): SubscriberMetadata $flushAfterMessages = null; $shouldFlushMethod = null; $rollbackBatchMethod = null; + $partitionMethod = null; foreach ($methods as $method) { $attributes = $method->getAttributes(Subscribe::class); @@ -163,6 +169,22 @@ public function metadata(string $subscriber): SubscriberMetadata $setupMethod = $method->getName(); } + if ($method->getAttributes(Partition::class)) { + if ($partitionMethod !== null) { + throw new DuplicatePartitionMethod( + $subscriber, + $partitionMethod, + $method->getName(), + ); + } + + if (!$this->isPartitionMethodSignature($method)) { + throw InvalidPartitionMethod::invalidSignature($subscriber, $method->getName()); + } + + $partitionMethod = $method->getName(); + } + if ($method->getAttributes(Cleanup::class)) { if ($cleanupMethod !== null) { throw new DuplicateCleanupMethod( @@ -232,6 +254,10 @@ public function metadata(string $subscriber): SubscriberMetadata ); } + if ($partitionMethod !== null && str_contains($subscriberInfo->id, Subscription::PARTITION_SEPARATOR)) { + throw InvalidPartitionMethod::invalidSubscriberId($subscriber, $subscriberInfo->id); + } + $metadata = new SubscriberMetadata( $subscriberInfo->id, $subscriberInfo->group, @@ -245,6 +271,7 @@ public function metadata(string $subscriber): SubscriberMetadata $reflector->getAttributes(EnableEventEmittingDuringBoot::class) !== [], $reflector->getAttributes(DisableEventEmitting::class) !== [], $batch, + $partitionMethod, ); $this->subscriberMetadata[$subscriber] = $metadata; @@ -283,6 +310,19 @@ private function subscribeMethod(ReflectionMethod $method): SubscribeMethodMetad ); } + private function isPartitionMethodSignature(ReflectionMethod $method): bool + { + $parameters = $method->getParameters(); + + if (count($parameters) !== 1) { + return false; + } + + $type = $parameters[0]->getType(); + + return $type instanceof ReflectionNamedType && $type->getName() === Message::class; + } + /** @param array $subscribeMethods */ private function hasBatchArgument(array $subscribeMethods): bool { diff --git a/src/Metadata/Subscriber/DuplicatePartitionMethod.php b/src/Metadata/Subscriber/DuplicatePartitionMethod.php new file mode 100644 index 000000000..225a5d8b8 --- /dev/null +++ b/src/Metadata/Subscriber/DuplicatePartitionMethod.php @@ -0,0 +1,25 @@ +eventDispatcher->addSubscriber( + new RemovePartitionsListener( + $this->subscriptionManager, + $this->eventDispatcher, + $this->logger, + ), + ); + $this->eventDispatcher->addSubscriber( new BatchSubscriber( $batchManager, diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php index 46648a80f..154a85980 100644 --- a/src/Subscription/Engine/Handler/RefreshHandler.php +++ b/src/Subscription/Engine/Handler/RefreshHandler.php @@ -76,7 +76,8 @@ public function __invoke(Command $command): Result ); } - $cleanupTasks = $this->cleanupTasks($subscriber); + // cleanup tasks belong to the subscriber as a whole and are only stored on its main subscription + $cleanupTasks = $subscription->isPartition() ? null : $this->cleanupTasks($subscriber); if ($subscription->cleanupTasks() !== $cleanupTasks) { $changed = true; diff --git a/src/Subscription/Engine/Handler/RemoveHandler.php b/src/Subscription/Engine/Handler/RemoveHandler.php index 4b1f19414..2bb2eb2e6 100644 --- a/src/Subscription/Engine/Handler/RemoveHandler.php +++ b/src/Subscription/Engine/Handler/RemoveHandler.php @@ -42,6 +42,7 @@ public function __invoke(Command $command): Result new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, + partitions: false, ), function (Subscription $subscription): Result { if ($subscription->isNew()) { diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index 0a98d1306..b3c4e50ac 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -36,6 +36,7 @@ public function __invoke(Command $command): ProcessedResult ids: $command->ids, groups: $command->groups, status: [Status::Active], + pendingPartitionsOnly: true, ), fn (Subscription $subscription): ProcessedResult => $this->runner->process( [$subscription], diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php index 90f3cc840..934eb2dae 100644 --- a/src/Subscription/Engine/Handler/SetupHandler.php +++ b/src/Subscription/Engine/Handler/SetupHandler.php @@ -49,6 +49,7 @@ public function __invoke(Command $command): Result ids: $command->ids, groups: $command->groups, status: [Status::New], + partitions: false, ), function (Subscription $subscription) use ($command, &$latestIndex): Result { $latestIndex ??= $this->messageLoader->lastIndex(); diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php index 4ab35565f..28cc26973 100644 --- a/src/Subscription/Engine/Handler/TeardownHandler.php +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -44,6 +44,7 @@ public function __invoke(Command $command): Result ids: $command->ids, groups: $command->groups, status: [Status::Detached], + partitions: false, ), function (Subscription $subscription): Result { if ($subscription->hasCleanupTasks()) { diff --git a/src/Subscription/Engine/InvalidPartitionKey.php b/src/Subscription/Engine/InvalidPartitionKey.php new file mode 100644 index 000000000..7316e2049 --- /dev/null +++ b/src/Subscription/Engine/InvalidPartitionKey.php @@ -0,0 +1,40 @@ +subscriberRepository->get($subscriberId); + // the batch belongs to the subscription, a partition shares its subscriber with the other partitions + $subscriber = $this->subscriberRepository->get($event->subscription->subscriberId()); $batchMetadata = $subscriber?->metadata()->batch; if ($subscriber === null || $batchMetadata === null) { diff --git a/src/Subscription/Engine/Listener/DiscoverSubscriber.php b/src/Subscription/Engine/Listener/DiscoverSubscriber.php index c02cfd788..28fa46940 100644 --- a/src/Subscription/Engine/Listener/DiscoverSubscriber.php +++ b/src/Subscription/Engine/Listener/DiscoverSubscriber.php @@ -51,7 +51,7 @@ public static function getSubscribedEvents(): array private function discover(): void { - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria()); + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria(partitions: false)); $latestIndex = null; diff --git a/src/Subscription/Engine/Listener/RemovePartitionsListener.php b/src/Subscription/Engine/Listener/RemovePartitionsListener.php new file mode 100644 index 000000000..b6921e889 --- /dev/null +++ b/src/Subscription/Engine/Listener/RemovePartitionsListener.php @@ -0,0 +1,62 @@ +subscription; + + if ($main->isPartition()) { + return; + } + + $partitions = $this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)); + + if ($partitions === []) { + return; + } + + foreach ($partitions as $partition) { + $this->subscriptionManager->remove($partition); + $this->eventDispatcher->dispatch(new OnSubscriptionRemoved($partition)); + } + + $this->logger?->info(sprintf( + 'Subscription Engine: Removed %d partitions of subscription "%s".', + count($partitions), + $main->id(), + )); + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + return [OnSubscriptionRemoved::class => 'onSubscriptionRemoved']; + } +} diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index 817617bcf..50f35c3d9 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -15,6 +15,7 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\EventArgumentResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\MessageArgumentResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\RecordedOnArgumentResolver; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; use Patchlevel\EventSourcing\Subscription\Subscriber\NoSuitableResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -55,8 +56,16 @@ public function __construct( ); } - public function process(int $index, Message $message, Subscription $subscription): Error|null - { + /** + * The router is required for the main subscription of a partitioned subscriber, which routes the messages into the + * inboxes of its partitions instead of calling the subscriber. + */ + public function process( + int $index, + Message $message, + Subscription $subscription, + PartitionRouter|null $router = null, + ): Error|null { $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); if (!$subscriber) { @@ -75,13 +84,24 @@ public function process(int $index, Message $message, Subscription $subscription ), ); - $event = new OnHandleMessageSuccess($subscription, $message, $index); - $this->eventDispatcher->dispatch($event); + $this->success($index, $message, $subscription); - if ($event->shouldChangePosition) { - $subscription->changePosition($index); + return null; + } + + if (!$subscription->isPartition() && $subscriber->metadata()->partitionMethod !== null) { + if ($router === null) { + throw new UnexpectedError(sprintf('The partitioned subscription "%s" needs a router.', $subscription->id())); } + try { + $router->route($index, $message, $subscription, $subscriber); + } catch (Throwable $e) { + return $this->error($index, $message, $subscription, $subscriber, $e); + } + + $this->success($index, $message, $subscription); + return null; } @@ -92,39 +112,12 @@ public function process(int $index, Message $message, Subscription $subscription ); $this->eventDispatcher->dispatch($event); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', - $subscriber::class, - $subscription->id(), - $message->event()::class, - $e->getMessage(), - ), - ); - - $this->eventDispatcher->dispatch( - new OnHandleMessageError( - $subscription, - $e, - $message, - $index, - ), - ); - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } - - try { $context = new ArgumentResolverContext($message, $subscription, $subscriber->metadata()); foreach ($subscribeMethods as $subscribeMethod) { $arguments = $this->resolveArguments( - $subscription->id(), + $subscription->subscriberId(), $message->event()::class, $subscriber->subscriber()::class, $subscribeMethod, @@ -134,54 +127,67 @@ public function process(int $index, Message $message, Subscription $subscription $subscriber->subscriber()->{$subscribeMethod->name}(...$arguments); } } 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(), - ), - ); + return $this->error($index, $message, $subscription, $subscriber, $e); + } - $this->eventDispatcher->dispatch( - new OnHandleMessageError( - $subscription, - $e, - $message, - $index, - ), - ); + $this->success($index, $message, $subscription); - return new Error( + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', + $subscriber::class, $subscription->id(), - $e->getMessage(), - $e, - ); - } - - $event = new OnHandleMessageSuccess( - $subscription, - $message, - $index, + $message->event()::class, + ), ); + return null; + } + + private function success(int $index, Message $message, Subscription $subscription): void + { + $event = new OnHandleMessageSuccess($subscription, $message, $index); $this->eventDispatcher->dispatch($event); - if ($event->shouldChangePosition) { - $subscription->changePosition($index); + if (!$event->shouldChangePosition) { + return; } - $this->logger?->debug( + $subscription->changePosition($index); + } + + /** @param MetadataSubscriberAccessor $subscriber */ + private function error( + int $index, + Message $message, + Subscription $subscription, + MetadataSubscriberAccessor $subscriber, + Throwable $throwable, + ): Error { + $this->logger?->error( sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', + 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', $subscriber::class, $subscription->id(), $message->event()::class, + $throwable->getMessage(), ), ); - return null; + $this->eventDispatcher->dispatch( + new OnHandleMessageError( + $subscription, + $throwable, + $message, + $index, + ), + ); + + return new Error( + $subscription->id(), + $throwable->getMessage(), + $throwable, + ); } /** @@ -191,13 +197,13 @@ public function process(int $index, Message $message, Subscription $subscription * @return list */ private function resolveArguments( - string $subscriptionId, + string $subscriberId, string $eventClass, string $subscriberClass, SubscribeMethodMetadata $method, ArgumentResolverContext $context, ): array { - $resolvers = $this->resolversFor($subscriptionId, $eventClass, $subscriberClass, $method); + $resolvers = $this->resolversFor($subscriberId, $eventClass, $subscriberClass, $method); $arguments = []; @@ -215,13 +221,13 @@ private function resolveArguments( * @return list */ private function resolversFor( - string $subscriptionId, + string $subscriberId, string $eventClass, string $subscriberClass, SubscribeMethodMetadata $method, ): array { - if (isset($this->resolverCache[$subscriptionId][$eventClass][$method->name])) { - return $this->resolverCache[$subscriptionId][$eventClass][$method->name]; + if (isset($this->resolverCache[$subscriberId][$eventClass][$method->name])) { + return $this->resolverCache[$subscriberId][$eventClass][$method->name]; } $resolvers = []; @@ -230,7 +236,7 @@ private function resolversFor( $resolvers[] = $this->resolverFor($argument, $eventClass, $subscriberClass, $method); } - return $this->resolverCache[$subscriptionId][$eventClass][$method->name] = $resolvers; + return $this->resolverCache[$subscriberId][$eventClass][$method->name] = $resolvers; } /** diff --git a/src/Subscription/Engine/PartitionInbox.php b/src/Subscription/Engine/PartitionInbox.php new file mode 100644 index 000000000..2d146ce41 --- /dev/null +++ b/src/Subscription/Engine/PartitionInbox.php @@ -0,0 +1,51 @@ + */ + private readonly array $lookup; + + /** @param list $indices in ascending order */ + public function __construct( + private readonly array $indices, + ) { + $this->lookup = array_fill_keys($indices, true); + } + + public function contains(int $index): bool + { + return isset($this->lookup[$index]); + } + + public function isEmpty(): bool + { + return $this->indices === []; + } + + public function count(): int + { + return count($this->indices); + } + + public function first(): int + { + return $this->indices[0] ?? throw new UnexpectedError('The inbox is empty.'); + } + + public function last(): int + { + return $this->indices[count($this->indices) - 1] ?? throw new UnexpectedError('The inbox is empty.'); + } +} diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php new file mode 100644 index 000000000..ff0e47c13 --- /dev/null +++ b/src/Subscription/Engine/PartitionRouter.php @@ -0,0 +1,109 @@ +> main subscription id => partition ids */ + private array $knownPartitions = []; + + private bool $routed = false; + + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly LoggerInterface|null $logger = null, + ) { + } + + /** @param MetadataSubscriberAccessor $subscriber */ + public function route(int $index, Message $message, Subscription $main, MetadataSubscriberAccessor $subscriber): void + { + $partitionKey = $this->partitionKey($message, $main, $subscriber); + $partitionId = Subscription::partitionId($main->id(), $partitionKey); + + $this->knownPartitions[$main->id()] ??= $this->loadPartitions($main); + + if (!isset($this->knownPartitions[$main->id()][$partitionId])) { + $this->subscriptionManager->add(Subscription::partition($main, $partitionKey, $index - 1)); + $this->knownPartitions[$main->id()][$partitionId] = true; + + $this->logger?->info(sprintf( + 'Subscription Engine: Partition "%s" of subscriber "%s" created.', + $partitionKey, + $main->id(), + )); + } + + $this->subscriptionManager->addToInbox($partitionId, $index); + $this->routed = true; + } + + /** Whether messages were routed, then the partitions have new work. */ + public function hasRouted(): bool + { + return $this->routed; + } + + /** @param MetadataSubscriberAccessor $subscriber */ + private function partitionKey(Message $message, Subscription $main, MetadataSubscriberAccessor $subscriber): string + { + $partitionMethod = $subscriber->partitionMethod(); + + if ($partitionMethod === null) { + throw new UnexpectedError(sprintf('The subscriber of "%s" has no partition method.', $main->id())); + } + + $partitionKey = $partitionMethod($message); + + if (!is_string($partitionKey)) { + throw InvalidPartitionKey::notAString($main->id(), $partitionKey); + } + + if ($partitionKey === '') { + throw InvalidPartitionKey::empty($main->id()); + } + + if (strlen(Subscription::partitionId($main->id(), $partitionKey)) > self::MAX_ID_LENGTH) { + throw InvalidPartitionKey::tooLong($main->id(), $partitionKey, self::MAX_ID_LENGTH); + } + + return $partitionKey; + } + + /** @return array */ + private function loadPartitions(Subscription $main): array + { + $partitions = []; + + foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { + $partitions[$partition->id()] = true; + } + + return $partitions; + } +} diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index d78b6b714..f60d4a03d 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -13,6 +13,7 @@ use SplObjectStorage; use Throwable; +use function max; use function sprintf; /** @internal */ @@ -27,6 +28,12 @@ final class SubscriptionManager /** @var SplObjectStorage */ private SplObjectStorage $forRemove; + /** @var array> */ + private array $forInbox = []; + + /** @var array */ + private array $forInboxRemove = []; + public function __construct( private readonly SubscriptionStore $subscriptionStore, private readonly LoggerInterface|null $logger = null, @@ -142,6 +149,28 @@ public function remove(Subscription ...$subscriptions): void } } + /** Routes a message into the inbox of a partition. */ + public function addToInbox(string $subscriptionId, int $index): void + { + $this->forInbox[$subscriptionId][] = $index; + } + + /** Removes the processed messages up to and including the given index from the inbox of a partition. */ + public function removeFromInbox(string $subscriptionId, int $toIndex): void + { + $this->forInboxRemove[$subscriptionId] = max($toIndex, $this->forInboxRemove[$subscriptionId] ?? $toIndex); + } + + /** + * @param positive-int $limit + * + * @return list + */ + public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + { + return $this->subscriptionStore->inbox($subscriptionId, $afterIndex, $limit); + } + public function flush(): void { foreach ($this->forAdd as $subscription) { @@ -172,6 +201,14 @@ public function flush(): void $this->subscriptionStore->remove($subscription); } + foreach ($this->forInbox as $subscriptionId => $indices) { + $this->subscriptionStore->addToInbox($subscriptionId, $indices); + } + + foreach ($this->forInboxRemove as $subscriptionId => $toIndex) { + $this->subscriptionStore->removeFromInbox($subscriptionId, $toIndex); + } + $this->clearPending(); } @@ -180,5 +217,7 @@ private function clearPending(): void $this->forAdd = new SplObjectStorage(); $this->forUpdate = new SplObjectStorage(); $this->forRemove = new SplObjectStorage(); + $this->forInbox = []; + $this->forInboxRemove = []; } } diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 4c95da82e..393706f52 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptionProcessed; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; @@ -14,11 +15,16 @@ use function array_merge; use function count; +use function max; +use function min; use function sprintf; /** @internal */ final class SubscriptionRunner { + /** How many messages of its inbox a partition processes at most in one run without a limit. */ + private const INBOX_LIMIT = 1000; + public function __construct( private readonly MessageLoader $messageLoader, private readonly SubscriptionManager $subscriptionManager, @@ -53,12 +59,10 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals return ProcessedResult::empty(); } - $startIndex = $members->lowestPosition(); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Event stream is processed from position %s.', - $startIndex ?? 'beginning', - )); + $inboxLimit = max(1, $limit ?? self::INBOX_LIMIT); + $inboxes = $this->loadInboxes($members, $inboxLimit); + $working = $this->workingMembers($members, $inboxes); + $router = new PartitionRouter($this->subscriptionManager, $this->logger); /** @var list $errors */ $errors = []; @@ -67,66 +71,209 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $lastIndex = null; $limitReached = false; - try { - $stream = $this->messageLoader->load($startIndex, $members->toArray()); + if (count($working) > 0) { + [$startIndex, $endIndex] = $this->range($working, $inboxes); + + $this->logger?->debug(sprintf( + 'Subscription Engine: Event stream is processed from position %s.', + $startIndex ?? 'beginning', + )); - foreach ($stream as $index => $message) { - $messageCounter++; - $lastIndex = $index; + try { + $stream = $this->messageLoader->load($startIndex, $working->toArray()); - foreach ($members as $member) { - if ($member->position() >= $index) { - continue; + foreach ($stream as $index => $message) { + // a partition must not read past its inbox, the main subscription may not have routed these messages yet + if ($endIndex !== null && $index > $endIndex) { + break; } - $error = $this->messageProcessor->process($index, $message, $member); + $messageCounter++; + $lastIndex = $index; - if (!$error) { - continue; - } + foreach ($working as $member) { + if ($member->position() >= $index) { + continue; + } + + if ($member->isPartition() && !$inboxes[$member->id()]->contains($index)) { + continue; + } - $errors[] = $error; - $members->remove($member); + $error = $this->messageProcessor->process($index, $message, $member, $router); - if (count($members) === 0) { - break 2; + if (!$error) { + continue; + } + + $errors[] = $error; + $working->remove($member); + $members->remove($member); + + if (count($working) === 0) { + break 2; + } } - } - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info(sprintf( - 'Subscription Engine: Message limit (%d) reached, finish processing.', - $limit, - )); + if ($limit !== null && $messageCounter >= $limit) { + $this->logger?->info(sprintf( + 'Subscription Engine: Message limit (%d) reached, finish processing.', + $limit, + )); - $limitReached = true; + $limitReached = true; - break; + break; + } } + } finally { + $stream?->close(); } - } finally { - $stream?->close(); } if ($lastIndex !== null) { - foreach ($members as $member) { + foreach ($working as $member) { $event = new OnSubscriptionProcessed($member, $lastIndex); $this->eventDispatcher->dispatch($event); $errors = array_merge($errors, $event->errors); } - } - if ($lastIndex !== null) { - foreach ($members as $member) { + foreach ($working as $member) { + if ($member->isPartition()) { + $this->finishInbox($member, $inboxes[$member->id()]); + } + $this->subscriptionManager->update($member); } } - if (!$limitReached) { + $inboxFull = $this->hasFullInbox($members, $inboxes, $inboxLimit); + + if (!$limitReached && !$inboxFull) { $this->finishMembers($members, $boot); } - return new ProcessedResult($messageCounter, !$limitReached, $errors); + return new ProcessedResult( + $messageCounter, + !$limitReached && !$inboxFull && !$router->hasRouted(), + $errors, + ); + } + + /** + * @param positive-int $limit + * + * @return array partition id => its inbox + */ + private function loadInboxes(SubscriptionCollection $members, int $limit): array + { + $inboxes = []; + + foreach ($members as $member) { + if (!$member->isPartition()) { + continue; + } + + $inboxes[$member->id()] = new PartitionInbox( + $this->subscriptionManager->inbox($member->id(), $member->position() ?? -1, $limit), + ); + } + + return $inboxes; + } + + /** @param array $inboxes */ + private function workingMembers(SubscriptionCollection $members, array $inboxes): SubscriptionCollection + { + $working = []; + + foreach ($members as $member) { + if ($member->isPartition() && $inboxes[$member->id()]->isEmpty()) { + continue; + } + + $working[] = $member; + } + + return new SubscriptionCollection($working); + } + + /** + * A partition reads only the range of its inbox. The range is unbounded as soon as a subscription + * reads the whole stream. + * + * @param array $inboxes + * + * @return array{int|null, int|null} + */ + private function range(SubscriptionCollection $working, array $inboxes): array + { + $startIndex = null; + $endIndex = null; + $first = true; + $bounded = true; + + foreach ($working as $member) { + if ($member->isPartition()) { + $inbox = $inboxes[$member->id()]; + $start = $inbox->first() - 1; + $endIndex = max($endIndex ?? 0, $inbox->last()); + } else { + $start = $member->position(); + $bounded = false; + } + + if ($first) { + $startIndex = $start; + $first = false; + + continue; + } + + if ($startIndex === null || $start === null) { + $startIndex = null; + + continue; + } + + $startIndex = min($startIndex, $start); + } + + return [$startIndex, $bounded ? $endIndex : null]; + } + + /** + * A partition never moves past its inbox, otherwise it would skip messages the main subscription routes later. + * Everything up to the new position has been processed and is removed from the inbox. + */ + private function finishInbox(Subscription $partition, PartitionInbox $inbox): void + { + $position = $partition->position(); + + if ($position === null) { + return; + } + + $end = $inbox->last(); + + if ($position > $end) { + $partition->changePosition($end); + $position = $end; + } + + $this->subscriptionManager->removeFromInbox($partition->id(), $position); + } + + /** @param array $inboxes */ + private function hasFullInbox(SubscriptionCollection $members, array $inboxes, int $limit): bool + { + foreach ($members as $member) { + if ($member->isPartition() && $inboxes[$member->id()]->count() >= $limit) { + return true; + } + } + + return false; } private function finishMembers(SubscriptionCollection $members, bool $boot): void @@ -137,7 +284,12 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi continue; } - if ($member->runMode() === RunMode::Once) { + // the main subscription may still route messages into the inbox of a booting partition + if ($member->isPartition() && $this->main($member)?->isBooting()) { + continue; + } + + if ($member->runMode() === RunMode::Once && $this->isRoutingFinished($member)) { $member->finished(); $this->logger?->info(sprintf( @@ -162,6 +314,10 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi continue; } + if (!$this->isRoutingFinished($member)) { + continue; + } + $member->finished(); $this->subscriptionManager->update($member); @@ -171,4 +327,21 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi )); } } + + /** A partition is only done when its main subscription has routed all messages. */ + private function isRoutingFinished(Subscription $subscription): bool + { + if (!$subscription->isPartition()) { + return true; + } + + return $this->main($subscription)?->isFinished() ?? true; + } + + private function main(Subscription $partition): Subscription|null + { + return $this->subscriptionManager->find( + new SubscriptionCriteria(ids: [$partition->subscriberId()], partitions: false), + )[0] ?? null; + } } diff --git a/src/Subscription/InvalidPartitionId.php b/src/Subscription/InvalidPartitionId.php new file mode 100644 index 000000000..15bdd4f95 --- /dev/null +++ b/src/Subscription/InvalidPartitionId.php @@ -0,0 +1,23 @@ +inboxTableName = $inboxTableName ?? $tableName . '_inbox'; } public function get(string $subscriptionId): Subscription @@ -125,11 +132,16 @@ public function claim(string $id, SubscriptionCriteria $criteria): Subscription| private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria): void { if ($criteria->ids !== null) { - $qb->andWhere('id IN (:ids)') + $qb->andWhere('id IN (:ids) OR subscriber_id IN (:subscriber_ids)') ->setParameter( 'ids', $criteria->ids, ArrayParameterType::STRING, + ) + ->setParameter( + 'subscriber_ids', + $criteria->ids, + ArrayParameterType::STRING, ); } @@ -142,16 +154,28 @@ private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria) ); } - if ($criteria->status === null) { + if ($criteria->status !== null) { + $qb->andWhere('status IN (:status)') + ->setParameter( + 'status', + array_map(static fn (Status $status) => $status->value, $criteria->status), + ArrayParameterType::STRING, + ); + } + + if ($criteria->partitions !== null) { + $qb->andWhere($criteria->partitions ? 'partition_key IS NOT NULL' : 'partition_key IS NULL'); + } + + if (!$criteria->pendingPartitionsOnly) { return; } - $qb->andWhere('status IN (:status)') - ->setParameter( - 'status', - array_map(static fn (Status $status) => $status->value, $criteria->status), - ArrayParameterType::STRING, - ); + $qb->andWhere(sprintf( + '%1$s.partition_key IS NULL OR EXISTS (SELECT 1 FROM %2$s WHERE %2$s.subscription_id = %1$s.id AND %2$s.message_index > %1$s.position)', + $this->tableName, + $this->inboxTableName, + )); } public function add(Subscription $subscription): void @@ -175,6 +199,8 @@ public function add(Subscription $subscription): void 'retry_attempt' => $subscription->retryAttempt(), 'last_saved_at' => $subscription->lastSavedAt(), 'cleanup_tasks' => $subscription->cleanupTasks() !== null ? serialize($subscription->cleanupTasks()) : null, + 'subscriber_id' => $subscription->subscriberId(), + 'partition_key' => $subscription->partitionKey(), ], [ 'last_saved_at' => Types::DATETIME_IMMUTABLE, @@ -236,6 +262,60 @@ public function update(Subscription $subscription): void public function remove(Subscription $subscription): void { $this->connection->delete($this->tableName, ['id' => $subscription->id()]); + $this->connection->delete($this->inboxTableName, ['subscription_id' => $subscription->id()]); + } + + /** @param list $indices */ + public function addToInbox(string $subscriptionId, array $indices): void + { + foreach ($indices as $index) { + $this->connection->insert( + $this->inboxTableName, + [ + 'subscription_id' => $subscriptionId, + 'message_index' => $index, + ], + ); + } + } + + /** + * @param positive-int $limit + * + * @return list + */ + public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + { + $sql = $this->connection->createQueryBuilder() + ->select('message_index') + ->from($this->inboxTableName) + ->where('subscription_id = :subscription_id') + ->andWhere('message_index > :after_index') + ->orderBy('message_index') + ->setMaxResults($limit) + ->getSQL(); + + /** @var list $indices */ + $indices = $this->connection->fetchFirstColumn($sql, [ + 'subscription_id' => $subscriptionId, + 'after_index' => $afterIndex, + ]); + + return array_map(static fn (int|string $index): int => (int)$index, $indices); + } + + public function removeFromInbox(string $subscriptionId, int $toIndex): void + { + $this->connection->executeStatement( + sprintf( + 'DELETE FROM %s WHERE subscription_id = :subscription_id AND message_index <= :to_index', + $this->inboxTableName, + ), + [ + 'subscription_id' => $subscriptionId, + 'to_index' => $toIndex, + ], + ); } /** @@ -302,9 +382,27 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->addColumn('cleanup_tasks', Types::TEXT) ->setNotnull(false); + $table->addColumn('subscriber_id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('partition_key', Types::STRING) + ->setLength(255) + ->setNotnull(false); + $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); + $table->addIndex(['subscriber_id']); + + $inbox = $schema->createTable($this->inboxTableName); + + $inbox->addColumn('subscription_id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $inbox->addColumn('message_index', Types::INTEGER) + ->setNotnull(true); + + $inbox->setPrimaryKey(['subscription_id', 'message_index']); } /** @param Data $row */ @@ -327,6 +425,7 @@ private function createSubscription(array $row): Subscription $row['retry_attempt'], self::normalizeDateTime($row['last_saved_at'], $this->connection->getDatabasePlatform()), $row['cleanup_tasks'] !== null ? unserialize($row['cleanup_tasks']) : null, + $row['partition_key'], ); } diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index b6f44b311..dc991a53f 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -11,14 +11,20 @@ use function array_filter; use function array_key_exists; +use function array_keys; +use function array_slice; use function array_values; use function in_array; +use function sort; final class InMemorySubscriptionStore implements SubscriptionStore { /** @var array */ private array $subscriptions = []; + /** @var array> */ + private array $inbox = []; + /** @param list $subscriptions */ public function __construct( array $subscriptions = [], @@ -50,7 +56,7 @@ public function find(SubscriptionCriteria|null $criteria = null): array return array_values( array_filter( $subscriptions, - static fn (Subscription $subscription): bool => self::matches($subscription, $criteria), + fn (Subscription $subscription): bool => $this->matches($subscription, $criteria), ), ); } @@ -63,7 +69,7 @@ public function claim(string $id, SubscriptionCriteria $criteria): Subscription| $subscription = $this->subscriptions[$id]; - if (!self::matches($subscription, $criteria)) { + if (!$this->matches($subscription, $criteria)) { return null; } @@ -82,10 +88,13 @@ public function inLock(Closure $closure): mixed return $closure(); } - private static function matches(Subscription $subscription, SubscriptionCriteria $criteria): bool + private function matches(Subscription $subscription, SubscriptionCriteria $criteria): bool { if ($criteria->ids !== null) { - if (!in_array($subscription->id(), $criteria->ids, true)) { + if ( + !in_array($subscription->id(), $criteria->ids, true) + && !in_array($subscription->subscriberId(), $criteria->ids, true) + ) { return false; } } @@ -102,6 +111,14 @@ private static function matches(Subscription $subscription, SubscriptionCriteria } } + if ($criteria->partitions !== null && $subscription->isPartition() !== $criteria->partitions) { + return false; + } + + if ($criteria->pendingPartitionsOnly && $subscription->isPartition()) { + return $this->inbox($subscription->id(), $subscription->position() ?? -1, 1) !== []; + } + return true; } @@ -129,11 +146,48 @@ public function update(Subscription $subscription): void public function remove(Subscription $subscription): void { - unset($this->subscriptions[$subscription->id()]); + unset($this->subscriptions[$subscription->id()], $this->inbox[$subscription->id()]); + } + + /** @param list $indices */ + public function addToInbox(string $subscriptionId, array $indices): void + { + foreach ($indices as $index) { + $this->inbox[$subscriptionId][$index] = true; + } + } + + /** + * @param positive-int $limit + * + * @return list + */ + public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + { + $indices = array_values(array_filter( + array_keys($this->inbox[$subscriptionId] ?? []), + static fn (int $index): bool => $index > $afterIndex, + )); + + sort($indices); + + return array_slice($indices, 0, $limit); + } + + public function removeFromInbox(string $subscriptionId, int $toIndex): void + { + foreach (array_keys($this->inbox[$subscriptionId] ?? []) as $index) { + if ($index > $toIndex) { + continue; + } + + unset($this->inbox[$subscriptionId][$index]); + } } public function clear(): void { $this->subscriptions = []; + $this->inbox = []; } } diff --git a/src/Subscription/Store/SubscriptionCriteria.php b/src/Subscription/Store/SubscriptionCriteria.php index 22d176af8..8440a16a8 100644 --- a/src/Subscription/Store/SubscriptionCriteria.php +++ b/src/Subscription/Store/SubscriptionCriteria.php @@ -10,14 +10,19 @@ final class SubscriptionCriteria { /** - * @param list|null $ids + * @param list|null $ids matches the subscription id or the subscriber id, + * so the id of a partitioned subscriber includes all its partitions * @param list|null $groups * @param list|null $status + * @param bool|null $partitions true only matches partitions, false only the main subscriptions + * @param bool $pendingPartitionsOnly partitions only match if their inbox has messages after their position */ public function __construct( public readonly array|null $ids = null, public readonly array|null $groups = null, public readonly array|null $status = null, + public readonly bool|null $partitions = null, + public readonly bool $pendingPartitionsOnly = false, ) { } } diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index e68014c8e..508bfb267 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -24,9 +24,33 @@ public function add(Subscription $subscription): void; /** @throws SubscriptionNotFound */ public function update(Subscription $subscription): void; - /** @throws SubscriptionNotFound */ + /** + * Removes the subscription together with its inbox. + * + * @throws SubscriptionNotFound + */ public function remove(Subscription $subscription): void; + /** + * Adds message indices to the inbox of a partition. The main subscription of a partitioned subscriber routes every + * message exactly once, the partition then only processes the messages in its inbox. + * + * @param list $indices + */ + public function addToInbox(string $subscriptionId, array $indices): void; + + /** + * Returns the indices in the inbox after the given index in ascending order. + * + * @param positive-int $limit + * + * @return list + */ + public function inbox(string $subscriptionId, int $afterIndex, int $limit): array; + + /** Removes all indices up to and including the given index from the inbox. */ + public function removeFromInbox(string $subscriptionId, int $toIndex): void; + /** * @param Closure():T $closure * diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php index 2a0d18bc7..1f619eed0 100644 --- a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php @@ -84,6 +84,18 @@ public function failedMethod(): Closure|null return $this->subscriber->$method(...); } + /** @return Closure(Message):mixed|null */ + public function partitionMethod(): Closure|null + { + $method = $this->metadata->partitionMethod; + + if ($method === null) { + return null; + } + + return $this->subscriber->$method(...); + } + /** @return list */ public function events(): array { diff --git a/src/Subscription/Subscription.php b/src/Subscription/Subscription.php index 7767518c7..07a7ec5d0 100644 --- a/src/Subscription/Subscription.php +++ b/src/Subscription/Subscription.php @@ -7,10 +7,17 @@ use DateTimeImmutable; use Throwable; +use function str_ends_with; +use function strlen; +use function substr; + final class Subscription { public const DEFAULT_GROUP = 'default'; + /** Separates the subscriber id from the partition key in the id of a partition. */ + public const PARTITION_SEPARATOR = '#'; + /** @param list|null $cleanupTasks */ public function __construct( private readonly string $id, @@ -22,7 +29,36 @@ public function __construct( private int $retryAttempt = 0, private DateTimeImmutable|null $lastSavedAt = null, private array|null $cleanupTasks = null, + private readonly string|null $partitionKey = null, ) { + if ($partitionKey !== null && !str_ends_with($id, self::PARTITION_SEPARATOR . $partitionKey)) { + throw new InvalidPartitionId($id, $partitionKey); + } + } + + /** + * Creates the partition of a partitioned subscriber. The partition belongs to the same subscriber as the given main + * subscription and starts with its group, run mode and status. + */ + public static function partition(self $main, string $partitionKey, int $position): self + { + if ($main->isPartition()) { + throw new PartitionOfPartitionNotPossible($main->id()); + } + + return new self( + self::partitionId($main->id(), $partitionKey), + $main->group(), + $main->runMode(), + $main->status(), + $position, + partitionKey: $partitionKey, + ); + } + + public static function partitionId(string $subscriberId, string $partitionKey): string + { + return $subscriberId . self::PARTITION_SEPARATOR . $partitionKey; } public function id(): string @@ -30,9 +66,26 @@ public function id(): string return $this->id; } + /** + * The id of the subscriber, the same as the id for every subscription that is not a partition. + */ public function subscriberId(): string { - return $this->id; + if ($this->partitionKey === null) { + return $this->id; + } + + return substr($this->id, 0, -strlen(self::PARTITION_SEPARATOR . $this->partitionKey)); + } + + public function partitionKey(): string|null + { + return $this->partitionKey; + } + + public function isPartition(): bool + { + return $this->partitionKey !== null; } public function group(): string diff --git a/tests/Architecture/LayerDependenciesTest.php b/tests/Architecture/LayerDependenciesTest.php index 102b7c7cc..1fbf6e3bf 100644 --- a/tests/Architecture/LayerDependenciesTest.php +++ b/tests/Architecture/LayerDependenciesTest.php @@ -145,6 +145,7 @@ public function testMetadataSubscriberCanOnlyDependOnAllowedLayers(): Rule $this->layer('Metadata\Subscriber'), [ $this->layer('Attribute'), + $this->layer('Message'), $this->metadataLayer(), $this->layer('Subscription'), ], diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 623aebf51..3a23130c3 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1090,4 +1090,108 @@ private function ids(array $subscriptions): array { return array_map(static fn (Subscription $subscription) => $subscription->id(), $subscriptions); } + + public function testFindPartitions(): void + { + $store = $this->createStore(); + + $main = new Subscription('profile', status: Status::Active, position: 0); + $partition = Subscription::partition($main, 'tenant-a', 0); + $other = new Subscription('other'); + + $store->add($other); + $store->add($main); + $store->add($partition); + + self::assertSame( + ['profile', 'profile#tenant-a'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(ids: ['profile']))), + 'the subscriber id includes its partitions', + ); + self::assertSame( + ['profile#tenant-a'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(ids: ['profile#tenant-a']))), + 'a single partition can be targeted by its id', + ); + self::assertSame( + ['other', 'profile'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(partitions: false))), + ); + self::assertSame( + ['profile#tenant-a'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(partitions: true))), + ); + + $found = $store->get('profile#tenant-a'); + self::assertSame('profile', $found->subscriberId()); + self::assertSame('tenant-a', $found->partitionKey()); + } + + public function testInbox(): void + { + $store = $this->createStore(); + + $store->addToInbox('profile#a', [5, 3, 9]); + $store->addToInbox('profile#b', [4]); + + self::assertSame([3, 5, 9], $store->inbox('profile#a', -1, 10)); + self::assertSame([5, 9], $store->inbox('profile#a', 3, 10)); + self::assertSame([3, 5], $store->inbox('profile#a', -1, 2)); + + $store->removeFromInbox('profile#a', 5); + + self::assertSame([9], $store->inbox('profile#a', -1, 10)); + self::assertSame([4], $store->inbox('profile#b', -1, 10)); + } + + public function testPendingPartitionsOnly(): void + { + $store = $this->createStore(); + + $main = new Subscription('profile', status: Status::Active, position: 0); + $store->add($main); + $store->add(Subscription::partition($main, 'pending', 3)); + $store->add(Subscription::partition($main, 'processed', 7)); + $store->add(Subscription::partition($main, 'empty', 0)); + + $store->addToInbox('profile#pending', [4]); + $store->addToInbox('profile#processed', [7]); + + self::assertSame( + ['profile', 'profile#pending'], + array_map( + static fn (Subscription $s) => $s->id(), + $store->find(new SubscriptionCriteria(pendingPartitionsOnly: true)), + ), + ); + + self::assertNotNull($store->claim('profile#pending', new SubscriptionCriteria(pendingPartitionsOnly: true))); + self::assertNull($store->claim('profile#empty', new SubscriptionCriteria(pendingPartitionsOnly: true))); + } + + public function testRemoveAlsoRemovesTheInbox(): void + { + $store = $this->createStore(); + + $partition = Subscription::partition(new Subscription('profile'), 'a', 0); + $store->add($partition); + $store->addToInbox($partition->id(), [1, 2]); + + $store->remove($partition); + $store->add($partition); + + self::assertSame([], $store->inbox($partition->id(), -1, 10)); + } + + private function createStore(): DoctrineSubscriptionStore + { + $store = new DoctrineSubscriptionStore( + $this->connection, + new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')), + ); + + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + return $store; + } } diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php new file mode 100644 index 000000000..8bb5db289 --- /dev/null +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -0,0 +1,394 @@ + */ + private Repository $repository; + + public function setUp(): void + { + $this->connection = DbalManager::createConnection(); + $this->clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); + } + + public function tearDown(): void + { + $this->connection->close(); + + gc_collect_cycles(); + } + + public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(3); + + $result = $engine->execute(new Run()); + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertFalse($result->finished, 'the routed messages still have to be processed by the partitions'); + + self::assertSame(0, $projection->handled, 'the main subscription never calls the subscriber'); + + $partitions = $this->partitions(); + self::assertCount(3, $partitions); + + foreach ($ids as $id) { + $partition = $partitions[Subscription::partitionId(self::MAIN, 'profile-' . $id)]; + + self::assertSame(self::MAIN, $partition->subscriberId()); + self::assertSame('profile-' . $id, $partition->partitionKey()); + self::assertSame(Status::Active, $partition->status()); + self::assertCount(1, $this->subscriptionStore->inbox($partition->id(), -1, 10)); + } + + $this->runUntilFinished($engine); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i], $projection->processed[$id]); + } + + foreach ($this->partitions() as $partition) { + self::assertSame([], $this->subscriptionStore->inbox($partition->id(), -1, 10), 'processed messages leave the inbox'); + } + } + + public function testFailureOnlyStopsTheAffectedPartition(): void + { + $projection = new PartitionedProfileProjection(); + $projection->failingNames = ['broken']; + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + [$brokenId, $healthyId] = $this->createProfiles(2, ['broken', 'healthy']); + + $this->runUntilFinished($engine); + + self::assertSame(['healthy'], $projection->processed[$healthyId]); + self::assertArrayNotHasKey($brokenId, $projection->processed); + + $partitions = $this->partitions(); + self::assertSame(Status::Error, $partitions[Subscription::partitionId(self::MAIN, 'profile-' . $brokenId)]->status()); + self::assertSame(Status::Active, $partitions[Subscription::partitionId(self::MAIN, 'profile-' . $healthyId)]->status()); + self::assertSame(Status::Active, $this->subscriptionStore->get(self::MAIN)->status()); + + $projection->failingNames = []; + $this->clock->sleep(10); + + $this->runUntilFinished($engine); + + self::assertSame(['broken'], $projection->processed[$brokenId]); + } + + /** + * The key is resolved once by the main subscription. A partition that is behind (here because of an error) still + * processes the messages routed to it, even if the key method returns something else by now. + */ + public function testChangedPartitionKeyDoesNotLoseMessages(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + $projection->partitionKey = static fn (Message $message): string => 'v1-' . $message->header(StreamNameHeader::class)->streamName; + + $engine->execute(new Setup(skipBooting: true)); + + $profileId = ProfileId::generate(); + $this->repository->save(Profile::create($profileId, 'n1')); + $this->runUntilFinished($engine); + + $projection->failingNames = ['n2']; + $this->changeName($profileId, 'n2'); + $this->runUntilFinished($engine); + + // deploy: the key method changes and the bug is fixed while the partition is still in error + $projection->partitionKey = static fn (Message $message): string => 'v2-' . $message->header(StreamNameHeader::class)->streamName; + $projection->failingNames = []; + $this->changeName($profileId, 'n3'); + + $this->clock->sleep(10); + $this->runUntilFinished($engine); + + $names = $projection->processed[$profileId->toString()]; + sort($names); + + self::assertSame(['n1', 'n2', 'n3'], $names); + self::assertCount(2, $this->partitions()); + + foreach ($this->partitions() as $partition) { + self::assertSame(Status::Active, $partition->status(), $partition->id()); + } + } + + public function testNonDeterministicPartitionKeyProcessesEveryMessageExactlyOnce(): void + { + $projection = new PartitionedProfileProjection(); + $projection->partitionKey = static fn (Message $message): string => 'bucket-' . random_int(1, 5); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(10); + + foreach ($ids as $id) { + $this->changeName(ProfileId::fromString($id), 'changed-' . $id); + } + + $this->runUntilFinished($engine); + + foreach ($ids as $i => $id) { + $names = $projection->processed[$id]; + sort($names); + + self::assertSame(['changed-' . $id, 'name-' . $i], $names); + } + } + + public function testIdlePartitionsAreNotClaimed(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + [$activeId, $idleId] = $this->createProfiles(2); + $this->runUntilFinished($engine); + + $idlePartitionId = Subscription::partitionId(self::MAIN, 'profile-' . $idleId); + $lastSavedAt = $this->subscriptionStore->get($idlePartitionId)->lastSavedAt(); + + $this->clock->sleep(60); + $this->changeName(ProfileId::fromString($activeId), 'changed'); + $this->runUntilFinished($engine); + + self::assertSame(['name-0', 'changed'], $projection->processed[$activeId]); + self::assertEquals( + $lastSavedAt, + $this->subscriptionStore->get($idlePartitionId)->lastSavedAt(), + 'a partition without messages in its inbox is not claimed', + ); + } + + public function testRunWithLimitProcessesTheInboxInChunks(): void + { + $projection = new PartitionedProfileProjection(); + $projection->partitionKey = static fn (Message $message): string => 'all'; + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(5); + + $this->runUntilFinished($engine, 2); + + self::assertSame($ids, array_keys($projection->processed), 'one partition keeps the order of the messages'); + } + + public function testBootProcessesThePartitions(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $ids = $this->createProfiles(3); + + $engine->execute(new Setup()); + + for ($i = 0; $i < 20; $i++) { + $result = $engine->execute(new Boot(limit: 1)); + self::assertInstanceOf(ProcessedResult::class, $result); + + if ($result->finished) { + break; + } + } + + self::assertSame(Status::Active, $this->subscriptionStore->get(self::MAIN)->status()); + + foreach ($this->partitions() as $partition) { + self::assertSame(Status::Active, $partition->status(), $partition->id()); + } + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i], $projection->processed[$id]); + } + } + + public function testRemoveAlsoRemovesThePartitions(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + $this->createProfiles(2); + $engine->execute(new Run()); + + self::assertCount(2, $this->partitions()); + + $engine->execute(new Remove()); + + self::assertSame([], $this->subscriptionStore->find(new SubscriptionCriteria(ids: [self::MAIN]))); + + self::assertSame([], $this->connection->fetchFirstColumn('SELECT subscription_id FROM subscriptions_inbox')); + } + + public function testBatchSubscriberWithPartitions(): void + { + $projection = new PartitionedBatchProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(3); + + foreach ($ids as $id) { + $this->changeName(ProfileId::fromString($id), 'changed-' . $id); + $this->changeName(ProfileId::fromString($id), 'again-' . $id); + } + + $this->runUntilFinished($engine); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i, 'changed-' . $id, 'again-' . $id], $projection->flushed[$id]); + } + + foreach ($this->partitions(PartitionedBatchProfileProjection::SUBSCRIBER_ID) as $partition) { + self::assertSame([], $this->subscriptionStore->inbox($partition->id(), -1, 10), $partition->id()); + } + } + + private function engine(object $subscriber): SubscriptionEngine + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + $this->subscriptionStore = new DoctrineSubscriptionStore($this->connection, $this->clock); + + $this->repository = (new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ))->get(Profile::class); + + (new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([$store, $this->subscriptionStore]), + ))->create(); + + $subscriberRepository = new MetadataSubscriberAccessorRepository([$subscriber]); + + return new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $this->subscriptionStore, + $subscriberRepository, + new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy($this->clock), + ]), + ); + } + + /** + * @param list $names + * + * @return list profile ids + */ + private function createProfiles(int $count, array $names = []): array + { + $ids = []; + + for ($i = 0; $i < $count; $i++) { + $profileId = ProfileId::generate(); + $this->repository->save(Profile::create($profileId, $names[$i] ?? 'name-' . $i)); + $ids[] = $profileId->toString(); + } + + return $ids; + } + + private function changeName(ProfileId $profileId, string $name): void + { + $profile = $this->repository->load($profileId); + $profile->changeName($name); + $this->repository->save($profile); + } + + /** @param positive-int|null $limit */ + private function runUntilFinished(SubscriptionEngine $engine, int|null $limit = null): void + { + for ($i = 0; $i < 50; $i++) { + $result = $engine->execute(new Run(limit: $limit)); + self::assertInstanceOf(ProcessedResult::class, $result); + + if ($result->finished) { + return; + } + } + + self::fail('The run did not finish.'); + } + + /** @return array partition id => partition */ + private function partitions(string $subscriberId = self::MAIN): array + { + $partitions = []; + + foreach ($this->subscriptionStore->find(new SubscriptionCriteria(ids: [$subscriberId], partitions: true)) as $partition) { + $partitions[$partition->id()] = $partition; + } + + return $partitions; + } +} diff --git a/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php new file mode 100644 index 000000000..ca7aa6f72 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php @@ -0,0 +1,69 @@ +> profile id => flushed names */ + public array $flushed = []; + + public int $flushCount = 0; + + #[Partition] + public function partition(Message $message): string + { + return $message->header(StreamNameHeader::class)->streamName; + } + + /** @param ArrayObject $state */ + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event, #[BatchState] + ArrayObject $state,): void + { + $state[] = [$event->profileId->toString(), $event->name]; + } + + /** @param ArrayObject $state */ + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $event, #[BatchState] + ArrayObject $state,): void + { + $state[] = [$event->profileId->toString(), $event->name]; + } + + /** @return ArrayObject */ + #[BatchBegin] + public function begin(): ArrayObject + { + return new ArrayObject(); + } + + /** @param ArrayObject $state */ + #[BatchFlush(afterMessages: 2)] + public function flush(ArrayObject $state): void + { + $this->flushCount++; + + foreach ($state as [$profileId, $name]) { + $this->flushed[$profileId][] = $name; + } + } +} diff --git a/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php new file mode 100644 index 000000000..482db6a14 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php @@ -0,0 +1,68 @@ +> profile id => processed names in processing order */ + public array $processed = []; + + public int $handled = 0; + + /** @var list names that throw while processing */ + public array $failingNames = []; + + /** @var Closure(Message):string */ + public Closure $partitionKey; + + public function __construct() + { + $this->partitionKey = static fn (Message $message): string => $message->header(StreamNameHeader::class)->streamName; + } + + #[Partition] + public function partition(Message $message): string + { + return ($this->partitionKey)($message); + } + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + private function record(string $profileId, string $name): void + { + $this->handled++; + + if (in_array($name, $this->failingNames, true)) { + throw new RuntimeException('Forced failure for ' . $name); + } + + $this->processed[$profileId][] = $name; + } +} diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index 3cbf71c69..50a912f0e 100644 --- a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -13,6 +13,7 @@ use Patchlevel\EventSourcing\Attribute\DisableEventEmitting; use Patchlevel\EventSourcing\Attribute\EnableEventEmittingDuringBoot; use Patchlevel\EventSourcing\Attribute\OnFailed; +use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Processor; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\RetryStrategy; @@ -30,12 +31,14 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateCleanupMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateFailedMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateFlushMethod; +use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicatePartitionMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateRollbackBatchMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSetupMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateShouldFlushMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSubscribeMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateTeardownMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\IncompleteBatchMethods; +use Patchlevel\EventSourcing\Metadata\Subscriber\InvalidPartitionMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\MixedTeardownAndCleanupMethods; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -284,6 +287,7 @@ public function testMissingArgumentType(): void $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] class { // phpcs:disable + // @phpstan-ignore missingType.parameter (the missing type is what this test is about) #[Subscribe(ProfileVisited::class)] public function profileVisited($message): void { @@ -801,4 +805,107 @@ class { self::assertSame('custom_strategy', $metadata->retryStrategy); } + + public function testPartitionMethod(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Partition] + public function partition(Message $message): string + { + return 'key'; + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertSame('partition', $metadata->partitionMethod); + } + + public function testNoPartitionMethod(): void + { + $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] + class { + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertNull($metadata->partitionMethod); + } + + public function testDuplicatePartitionMethodException(): void + { + $this->expectException(DuplicatePartitionMethod::class); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Partition] + public function partition1(Message $message): string + { + return 'key'; + } + + #[Partition] + public function partition2(Message $message): string + { + return 'key'; + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testPartitionMethodWithInvalidSignature(): void + { + $this->expectException(InvalidPartitionMethod::class); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Partition] + public function partition(Message $message, string $other): string + { + return $other; + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testPartitionMethodWithEventArgument(): void + { + $this->expectException(InvalidPartitionMethod::class); + + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Partition] + public function partition(ProfileVisited $event): string + { + return 'key'; + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } + + public function testPartitionedSubscriberIdWithSeparator(): void + { + $this->expectException(InvalidPartitionMethod::class); + + $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] + class { + #[Partition] + public function partition(Message $message): string + { + return 'key'; + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadataFactory->metadata($subscriber::class); + } } diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 491ac0bf6..573974ec9 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -83,6 +83,27 @@ public function remove(Subscription $subscription): void $this->removedSubscriptions[] = clone $subscription; } + /** @param list $indices */ + public function addToInbox(string $subscriptionId, array $indices): void + { + $this->parentStore->addToInbox($subscriptionId, $indices); + } + + /** + * @param positive-int $limit + * + * @return list + */ + public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + { + return $this->parentStore->inbox($subscriptionId, $afterIndex, $limit); + } + + public function removeFromInbox(string $subscriptionId, int $toIndex): void + { + $this->parentStore->removeFromInbox($subscriptionId, $toIndex); + } + public function reset(): void { $this->addedSubscriptions = []; diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index d53c70dbe..5b039c33e 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -61,6 +61,8 @@ public function testGet(): void 'retry_attempt' => 0, 'last_saved_at' => '2024-01-01 10:00:00', 'cleanup_tasks' => null, + 'subscriber_id' => 'foo', + 'partition_key' => null, ]); $store = new DoctrineSubscriptionStore($connection); @@ -111,6 +113,8 @@ public function testGetWithErrorAndCleanupTasks(): void 'retry_attempt' => 2, 'last_saved_at' => '2024-01-01 10:00:00', 'cleanup_tasks' => serialize([$task]), + 'subscriber_id' => 'foo', + 'partition_key' => null, ]); $store = new DoctrineSubscriptionStore($connection); @@ -187,6 +191,8 @@ public function testFind(): void 'retry_attempt' => 0, 'last_saved_at' => '2024-01-01 10:00:00', 'cleanup_tasks' => null, + 'subscriber_id' => 'foo', + 'partition_key' => null, ], ]); $connection @@ -225,9 +231,10 @@ public function testFindWithCriteria(): void ->expects($this->once()) ->method('executeQuery') ->with( - 'SELECT * FROM subscriptions WHERE (id IN (:ids)) AND (group_name IN (:groups)) AND (status IN (:status)) ORDER BY id', + 'SELECT * FROM subscriptions WHERE (id IN (:ids) OR subscriber_id IN (:subscriber_ids)) AND (group_name IN (:groups)) AND (status IN (:status)) ORDER BY id', [ 'ids' => ['foo'], + 'subscriber_ids' => ['foo'], 'groups' => ['default'], 'status' => ['active'], ], @@ -269,6 +276,8 @@ public function testClaim(): void 'retry_attempt' => 0, 'last_saved_at' => '2024-01-01 10:00:00', 'cleanup_tasks' => null, + 'subscriber_id' => 'foo', + 'partition_key' => null, ]); $connection ->expects($this->once()) @@ -347,6 +356,8 @@ public function testAdd(): void 'retry_attempt' => 0, 'last_saved_at' => $now, 'cleanup_tasks' => null, + 'subscriber_id' => 'foo', + 'partition_key' => null, ], ['last_saved_at' => Types::DATETIME_IMMUTABLE], ); @@ -384,6 +395,8 @@ public function testAddWithError(): void 'retry_attempt' => 0, 'last_saved_at' => $now, 'cleanup_tasks' => null, + 'subscriber_id' => 'foo', + 'partition_key' => null, ], ['last_saved_at' => Types::DATETIME_IMMUTABLE], ); @@ -476,9 +489,16 @@ public function testRemove(): void { $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) + ->expects($this->exactly(2)) ->method('delete') - ->with('subscriptions', ['id' => 'foo']); + ->willReturnCallback(static function (string $table, array $criteria): int { + self::assertContains([$table, $criteria], [ + ['subscriptions', ['id' => 'foo']], + ['subscriptions_inbox', ['subscription_id' => 'foo']], + ]); + + return 1; + }); $store = new DoctrineSubscriptionStore($connection); @@ -574,10 +594,25 @@ public function testConfigureSchema(): void ->setNotnull(true); $table->addColumn('cleanup_tasks', Types::TEXT) ->setNotnull(false); + $table->addColumn('subscriber_id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $table->addColumn('partition_key', Types::STRING) + ->setLength(255) + ->setNotnull(false); $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); + $table->addIndex(['subscriber_id']); + + $inbox = $expectedSchema->createTable('subscriptions_inbox'); + $inbox->addColumn('subscription_id', Types::STRING) + ->setLength(255) + ->setNotnull(true); + $inbox->addColumn('message_index', Types::INTEGER) + ->setNotnull(true); + $inbox->setPrimaryKey(['subscription_id', 'message_index']); $schema = new Schema(); $store->configureSchema($schema, $connection); diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index 67990b799..0c085545e 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -15,6 +15,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function array_map; + #[CoversClass(InMemorySubscriptionStore::class)] final class InMemorySubscriptionStoreTest extends TestCase { @@ -210,4 +212,96 @@ public function testInLock(): void self::assertSame($subscription, $store->inLock(static fn (): Subscription => $subscription)); } + + public function testFindPartitions(): void + { + $store = new InMemorySubscriptionStore(); + + $main = new Subscription('profile', status: Status::Active, position: 0); + $partition = Subscription::partition($main, 'tenant-a', 0); + $other = new Subscription('other'); + + $store->add($other); + $store->add($main); + $store->add($partition); + + self::assertSame( + ['profile', 'profile#tenant-a'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(ids: ['profile']))), + 'the subscriber id includes its partitions', + ); + self::assertSame( + ['profile#tenant-a'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(ids: ['profile#tenant-a']))), + 'a single partition can be targeted by its id', + ); + self::assertSame( + ['other', 'profile'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(partitions: false))), + ); + self::assertSame( + ['profile#tenant-a'], + array_map(static fn (Subscription $s) => $s->id(), $store->find(new SubscriptionCriteria(partitions: true))), + ); + + $found = $store->get('profile#tenant-a'); + self::assertSame('profile', $found->subscriberId()); + self::assertSame('tenant-a', $found->partitionKey()); + } + + public function testInbox(): void + { + $store = new InMemorySubscriptionStore(); + + $store->addToInbox('profile#a', [5, 3, 9]); + $store->addToInbox('profile#b', [4]); + + self::assertSame([3, 5, 9], $store->inbox('profile#a', -1, 10)); + self::assertSame([5, 9], $store->inbox('profile#a', 3, 10)); + self::assertSame([3, 5], $store->inbox('profile#a', -1, 2)); + + $store->removeFromInbox('profile#a', 5); + + self::assertSame([9], $store->inbox('profile#a', -1, 10)); + self::assertSame([4], $store->inbox('profile#b', -1, 10)); + } + + public function testPendingPartitionsOnly(): void + { + $store = new InMemorySubscriptionStore(); + + $main = new Subscription('profile', status: Status::Active, position: 0); + $store->add($main); + $store->add(Subscription::partition($main, 'pending', 3)); + $store->add(Subscription::partition($main, 'processed', 7)); + $store->add(Subscription::partition($main, 'empty', 0)); + + $store->addToInbox('profile#pending', [4]); + $store->addToInbox('profile#processed', [7]); + + self::assertSame( + ['profile', 'profile#pending'], + array_map( + static fn (Subscription $s) => $s->id(), + $store->find(new SubscriptionCriteria(pendingPartitionsOnly: true)), + ), + ); + + self::assertNotNull($store->claim('profile#pending', new SubscriptionCriteria(pendingPartitionsOnly: true))); + self::assertNull($store->claim('profile#empty', new SubscriptionCriteria(pendingPartitionsOnly: true))); + } + + public function testRemoveAlsoRemovesTheInbox(): void + { + $store = new InMemorySubscriptionStore(); + + $partition = Subscription::partition(new Subscription('profile'), 'a', 0); + $store->add($partition); + $store->addToInbox($partition->id(), [1, 2]); + + $store->remove($partition); + $store->add($partition); + + self::assertSame([], $store->inbox($partition->id(), -1, 10)); + } } diff --git a/tests/Unit/Subscription/SubscriptionTest.php b/tests/Unit/Subscription/SubscriptionTest.php index d28b2a314..cf0530632 100644 --- a/tests/Unit/Subscription/SubscriptionTest.php +++ b/tests/Unit/Subscription/SubscriptionTest.php @@ -5,7 +5,9 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription; use DateTimeImmutable; +use Patchlevel\EventSourcing\Subscription\InvalidPartitionId; use Patchlevel\EventSourcing\Subscription\NoErrorToRetry; +use Patchlevel\EventSourcing\Subscription\PartitionOfPartitionNotPossible; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -264,4 +266,46 @@ public function testCleanupTasks(): void self::assertSame([$task], $subscription->cleanupTasks()); self::assertTrue($subscription->hasCleanupTasks()); } + + public function testPartition(): void + { + $main = new Subscription('profile', 'projector', RunMode::Once, Status::Booting, 10); + + $partition = Subscription::partition($main, 'tenant-a', 4); + + self::assertSame('profile#tenant-a', $partition->id()); + self::assertSame('profile', $partition->subscriberId()); + self::assertSame('tenant-a', $partition->partitionKey()); + self::assertTrue($partition->isPartition()); + self::assertSame('projector', $partition->group()); + self::assertSame(RunMode::Once, $partition->runMode()); + self::assertSame(Status::Booting, $partition->status()); + self::assertSame(4, $partition->position()); + + self::assertFalse($main->isPartition()); + self::assertSame('profile', $main->subscriberId()); + self::assertNull($main->partitionKey()); + } + + public function testPartitionKeyMayContainTheSeparator(): void + { + $partition = Subscription::partition(new Subscription('profile'), 'a#b', 0); + + self::assertSame('profile#a#b', $partition->id()); + self::assertSame('profile', $partition->subscriberId()); + } + + public function testPartitionOfPartition(): void + { + $this->expectException(PartitionOfPartitionNotPossible::class); + + Subscription::partition(Subscription::partition(new Subscription('profile'), 'a', 0), 'b', 0); + } + + public function testInvalidPartitionId(): void + { + $this->expectException(InvalidPartitionId::class); + + new Subscription('profile#a', partitionKey: 'b'); + } } From d4a8e9591dc5d0c31e21cd28425277acab2ec908 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 14 Oct 2026 22:52:42 +0200 Subject: [PATCH 02/57] Read only the inbox of a partition and claim by pending work A partition now loads exactly the messages in its inbox through the new IndexCriterion instead of the whole index range between its first and last entry, which was as expensive as the stream was long for a key per aggregate. The run snapshot joins the inbox, so finding pending partitions costs as much as there is work, not as many partitions as there are. The router looks up only the partitions of the routed keys instead of loading all of them, and the detach check only claims main subscriptions, their partitions follow. Both scanned every partition on every run. Adds a benchmark with one partition per aggregate. --- docs/UPGRADE-4.0.md | 27 +++ docs/store.md | 8 + src/Store/Criteria/IndexCriterion.php | 19 ++ src/Store/InMemoryStore.php | 13 + src/Store/StreamDoctrineDbalStore.php | 5 + src/Store/TaggableDoctrineDbalStore.php | 5 + .../EventFilteredStoreMessageLoader.php | 7 + .../Engine/GapResolverStoreMessageLoader.php | 7 + .../Engine/Listener/DetachListener.php | 13 + src/Subscription/Engine/MessageLoader.php | 7 + src/Subscription/Engine/PartitionInbox.php | 6 + src/Subscription/Engine/PartitionRouter.php | 25 +- .../Engine/StoreMessageLoader.php | 7 + .../Engine/SubscriptionRunner.php | 55 ++++- .../Store/DoctrineSubscriptionStore.php | 68 +++++- .../Projection/CountingProjector.php | 29 +++ .../PartitionedCountingProjector.php | 38 +++ .../PartitionedSubscriptionEngineBench.php | 224 ++++++++++++++++++ .../Store/StreamDoctrineDbalStoreTest.php | 30 +++ .../Store/TaggableDoctrineDbalStoreTest.php | 30 +++ tests/Unit/Store/InMemoryStoreTest.php | 23 ++ .../EventFilteredStoreMessageLoaderTest.php | 21 ++ .../GapResolverStoreMessageLoaderTest.php | 17 ++ .../Engine/StoreMessageLoaderTest.php | 17 ++ 24 files changed, 671 insertions(+), 30 deletions(-) create mode 100644 src/Store/Criteria/IndexCriterion.php create mode 100644 tests/Benchmark/BasicImplementation/Projection/CountingProjector.php create mode 100644 tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php create mode 100644 tests/Benchmark/PartitionedSubscriptionEngineBench.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 7f8c762d9..25f31a38d 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -314,6 +314,33 @@ final class CustomMessageLoader implements MessageLoader } } ``` +A message loader also has to implement `loadIndices()`, which loads only the messages with the given indices. +Partitions use it to read just the messages in their inbox. With a store, it is a load with the new `IndexCriterion`: + +```php +use Patchlevel\EventSourcing\Message\Stream; +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; +use Patchlevel\EventSourcing\Store\Store; +use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; + +final class CustomMessageLoader implements MessageLoader +{ + public function __construct( + private readonly Store $store, + ) { + } + + /** @param non-empty-list $indices */ + public function loadIndices(array $indices): Stream + { + return $this->store->load(new Criteria(new IndexCriterion(...$indices))); + } + + // ... +} +``` +A custom store has to support the `IndexCriterion` as well. ### Batchable Subscriber The `Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber` interface has been removed. diff --git a/docs/store.md b/docs/store.md index 6b9c111e0..8da490d08 100644 --- a/docs/store.md +++ b/docs/store.md @@ -370,6 +370,14 @@ use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; $criterion = new StreamCriterion('profile-*', 'hotel-*'); $criterion = StreamCriterion::startWith('profile-'); ``` +To load specific messages by their index, use the `IndexCriterion`. + +```php +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; + +$criteria = new Criteria(new IndexCriterion(12, 57, 103)); +``` Or you can the criteria builder to create the criteria. ```php diff --git a/src/Store/Criteria/IndexCriterion.php b/src/Store/Criteria/IndexCriterion.php new file mode 100644 index 000000000..c59e66475 --- /dev/null +++ b/src/Store/Criteria/IndexCriterion.php @@ -0,0 +1,19 @@ + */ + public readonly array $indices; + + public function __construct(int ...$indices) + { + $this->indices = array_values($indices); + } +} diff --git a/src/Store/InMemoryStore.php b/src/Store/InMemoryStore.php index ddad4022d..22e4d0c05 100644 --- a/src/Store/InMemoryStore.php +++ b/src/Store/InMemoryStore.php @@ -15,6 +15,7 @@ use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; use Patchlevel\EventSourcing\Store\Header\EventIdHeader; @@ -293,6 +294,18 @@ static function (Message $message) use ($criteria, $eventRegistry): bool { return false; } + break; + case IndexCriterion::class: + try { + $index = $message->header(IndexHeader::class)->index; + } catch (HeaderNotFound) { + return false; + } + + if (!in_array($index, $criterion->indices, true)) { + return false; + } + break; case EventsCriterion::class: if ($eventRegistry === null) { diff --git a/src/Store/StreamDoctrineDbalStore.php b/src/Store/StreamDoctrineDbalStore.php index 20fcbd9c8..e7215baa8 100644 --- a/src/Store/StreamDoctrineDbalStore.php +++ b/src/Store/StreamDoctrineDbalStore.php @@ -35,6 +35,7 @@ use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToPlayheadCriterion; @@ -216,6 +217,10 @@ private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void $builder->andWhere('id < :to_index'); $builder->setParameter('to_index', $criterion->toIndex, Types::INTEGER); break; + case IndexCriterion::class: + $builder->andWhere('id IN (:indices)'); + $builder->setParameter('indices', $criterion->indices, ArrayParameterType::INTEGER); + break; case EventsCriterion::class: $builder->andWhere('event_name IN (:events)'); $builder->setParameter('events', $criterion->events, ArrayParameterType::STRING); diff --git a/src/Store/TaggableDoctrineDbalStore.php b/src/Store/TaggableDoctrineDbalStore.php index 26a0a1434..1603976cd 100644 --- a/src/Store/TaggableDoctrineDbalStore.php +++ b/src/Store/TaggableDoctrineDbalStore.php @@ -38,6 +38,7 @@ use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\TagCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; @@ -248,6 +249,10 @@ private function applyCriteria(QueryBuilder $builder, Criteria $criteria): void $builder->andWhere('id < :to_index'); $builder->setParameter('to_index', $criterion->toIndex, Types::INTEGER); break; + case IndexCriterion::class: + $builder->andWhere('id IN (:indices)'); + $builder->setParameter('indices', $criterion->indices, ArrayParameterType::INTEGER); + break; case EventsCriterion::class: $builder->andWhere('event_name IN (:events)'); $builder->setParameter('events', $criterion->events, ArrayParameterType::STRING); diff --git a/src/Subscription/Engine/EventFilteredStoreMessageLoader.php b/src/Subscription/Engine/EventFilteredStoreMessageLoader.php index c915b4708..6d33fe773 100644 --- a/src/Subscription/Engine/EventFilteredStoreMessageLoader.php +++ b/src/Subscription/Engine/EventFilteredStoreMessageLoader.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; @@ -79,6 +80,12 @@ private function events(array $subscriptions): array return array_keys($eventNames); } + /** @param non-empty-list $indices */ + public function loadIndices(array $indices): Stream + { + return $this->store->load(new Criteria(new IndexCriterion(...$indices))); + } + public function lastIndex(): int { $stream = $this->store->load(null, 1, null, true); diff --git a/src/Subscription/Engine/GapResolverStoreMessageLoader.php b/src/Subscription/Engine/GapResolverStoreMessageLoader.php index 4f1016f55..58941f5cc 100644 --- a/src/Subscription/Engine/GapResolverStoreMessageLoader.php +++ b/src/Subscription/Engine/GapResolverStoreMessageLoader.php @@ -11,6 +11,7 @@ use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -37,6 +38,12 @@ public function load(int|null $startIndex, array $subscriptions): Stream ); } + /** @param non-empty-list $indices */ + public function loadIndices(array $indices): Stream + { + return $this->store->load(new Criteria(new IndexCriterion(...$indices))); + } + public function lastIndex(): int { $stream = $this->store->load(null, 1, null, true); diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php index 638fa018e..9c2b93c5a 100644 --- a/src/Subscription/Engine/Listener/DetachListener.php +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -38,6 +38,8 @@ public function __invoke(OnCommand $event): void ids: $command->ids, groups: $command->groups, status: [Status::Active, Status::Paused, Status::Finished], + // partitions follow their main subscription, checking each of them would cost a claim per partition + partitions: false, ), function (Subscription $subscription): void { $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); @@ -49,6 +51,17 @@ function (Subscription $subscription): void { $subscription->detached(); $this->subscriptionManager->update($subscription); + $partitions = $this->subscriptionManager->find(new SubscriptionCriteria( + ids: [$subscription->id()], + status: [Status::Active, Status::Paused, Status::Finished], + partitions: true, + )); + + foreach ($partitions as $partition) { + $partition->detached(); + $this->subscriptionManager->update($partition); + } + $this->logger?->info( sprintf( 'Subscription Engine: Subscriber for "%s" not found and has been marked as detached.', diff --git a/src/Subscription/Engine/MessageLoader.php b/src/Subscription/Engine/MessageLoader.php index 01f5a860f..d147cf181 100644 --- a/src/Subscription/Engine/MessageLoader.php +++ b/src/Subscription/Engine/MessageLoader.php @@ -12,5 +12,12 @@ interface MessageLoader /** @param list $subscriptions */ public function load(int|null $startIndex, array $subscriptions): Stream; + /** + * Loads only the messages with the given indices in ascending order, e.g. the inbox of a partition. + * + * @param non-empty-list $indices + */ + public function loadIndices(array $indices): Stream; + public function lastIndex(): int; } diff --git a/src/Subscription/Engine/PartitionInbox.php b/src/Subscription/Engine/PartitionInbox.php index 2d146ce41..45e877689 100644 --- a/src/Subscription/Engine/PartitionInbox.php +++ b/src/Subscription/Engine/PartitionInbox.php @@ -24,6 +24,12 @@ public function __construct( $this->lookup = array_fill_keys($indices, true); } + /** @return list */ + public function indices(): array + { + return $this->indices; + } + public function contains(int $index): bool { return isset($this->lookup[$index]); diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index ff0e47c13..7610aade2 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -29,7 +29,7 @@ final class PartitionRouter /** Maximum length of a subscription id in bytes, the id of a partition has to fit into it. */ public const MAX_ID_LENGTH = 255; - /** @var array> main subscription id => partition ids */ + /** @var array partition ids known to exist */ private array $knownPartitions = []; private bool $routed = false; @@ -46,11 +46,9 @@ public function route(int $index, Message $message, Subscription $main, Metadata $partitionKey = $this->partitionKey($message, $main, $subscriber); $partitionId = Subscription::partitionId($main->id(), $partitionKey); - $this->knownPartitions[$main->id()] ??= $this->loadPartitions($main); - - if (!isset($this->knownPartitions[$main->id()][$partitionId])) { + if (!$this->exists($partitionId)) { $this->subscriptionManager->add(Subscription::partition($main, $partitionKey, $index - 1)); - $this->knownPartitions[$main->id()][$partitionId] = true; + $this->knownPartitions[$partitionId] = true; $this->logger?->info(sprintf( 'Subscription Engine: Partition "%s" of subscriber "%s" created.', @@ -95,15 +93,20 @@ private function partitionKey(Message $message, Subscription $main, MetadataSubs return $partitionKey; } - /** @return array */ - private function loadPartitions(Subscription $main): array + /** + * Looks up only the partitions the routed messages belong to, so routing a few messages stays cheap + * no matter how many partitions the subscriber has. + */ + private function exists(string $partitionId): bool { - $partitions = []; + if (isset($this->knownPartitions[$partitionId])) { + return true; + } - foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { - $partitions[$partition->id()] = true; + if ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { + return false; } - return $partitions; + return $this->knownPartitions[$partitionId] = true; } } diff --git a/src/Subscription/Engine/StoreMessageLoader.php b/src/Subscription/Engine/StoreMessageLoader.php index 44fc508da..e39677ccc 100644 --- a/src/Subscription/Engine/StoreMessageLoader.php +++ b/src/Subscription/Engine/StoreMessageLoader.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -29,6 +30,12 @@ public function load(int|null $startIndex, array $subscriptions): Stream return $this->store->load($criteria); } + /** @param non-empty-list $indices */ + public function loadIndices(array $indices): Stream + { + return $this->store->load(new Criteria(new IndexCriterion(...$indices))); + } + public function lastIndex(): int { $stream = $this->store->load(null, 1, null, true); diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 393706f52..296997bb2 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -14,7 +14,9 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface; use function array_merge; +use function array_values; use function count; +use function ksort; use function max; use function min; use function sprintf; @@ -73,14 +75,25 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals if (count($working) > 0) { [$startIndex, $endIndex] = $this->range($working, $inboxes); - - $this->logger?->debug(sprintf( - 'Subscription Engine: Event stream is processed from position %s.', - $startIndex ?? 'beginning', - )); + $indices = $this->inboxIndices($working, $inboxes); try { - $stream = $this->messageLoader->load($startIndex, $working->toArray()); + if ($indices !== null) { + // only partitions, so only the messages of their inboxes have to be loaded + $this->logger?->debug(sprintf( + 'Subscription Engine: %d messages of the inbox are processed.', + count($indices), + )); + + $stream = $this->messageLoader->loadIndices($indices); + } else { + $this->logger?->debug(sprintf( + 'Subscription Engine: Event stream is processed from position %s.', + $startIndex ?? 'beginning', + )); + + $stream = $this->messageLoader->load($startIndex, $working->toArray()); + } foreach ($stream as $index => $message) { // a partition must not read past its inbox, the main subscription may not have routed these messages yet @@ -242,6 +255,36 @@ private function range(SubscriptionCollection $working, array $inboxes): array return [$startIndex, $bounded ? $endIndex : null]; } + /** + * The indices of all inboxes, or null if a subscription has to read the stream. + * + * @param array $inboxes + * + * @return non-empty-list|null + */ + private function inboxIndices(SubscriptionCollection $working, array $inboxes): array|null + { + $indices = []; + + foreach ($working as $member) { + if (!$member->isPartition()) { + return null; + } + + foreach ($inboxes[$member->id()]->indices() as $index) { + $indices[$index] = $index; + } + } + + if ($indices === []) { + return null; + } + + ksort($indices); + + return array_values($indices); + } + /** * A partition never moves past its inbox, otherwise it would skip messages the main subscription routes later. * Everything up to the new position has been processed and is removed from the inbox. diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 002f14a98..2b672372b 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -84,18 +84,60 @@ public function get(string $subscriptionId): Subscription return $this->createSubscription($result); } - /** @return list */ + /** + * With pendingPartitionsOnly, the main subscriptions come first, then the partitions with messages in their inbox. + * + * @return list + */ public function find(SubscriptionCriteria|null $criteria = null): array + { + if ($criteria === null || !$criteria->pendingPartitionsOnly) { + return $this->fetch($criteria); + } + + $mains = $criteria->partitions !== true ? $this->fetch($criteria, false) : []; + $partitions = $criteria->partitions !== false ? $this->fetch($criteria, true) : []; + + return [...$mains, ...$partitions]; + } + + /** + * Partitions with pending messages are found by joining the inbox, which only holds the unprocessed messages. + * So the query costs as much as there is work, not as many partitions as there are. + * + * @return list + */ + private function fetch(SubscriptionCriteria|null $criteria, bool|null $pendingPartitions = null): array { $qb = $this->connection->createQueryBuilder() - ->select('*') + ->select($pendingPartitions === true ? $this->tableName . '.*' : '*') ->from($this->tableName) - ->orderBy('id'); + ->orderBy($pendingPartitions === true ? $this->tableName . '.id' : 'id'); if ($criteria !== null) { $this->applyCriteria($qb, $criteria); } + if ($pendingPartitions === false) { + $qb->andWhere('partition_key IS NULL'); + } + + if ($pendingPartitions === true) { + $qb->andWhere('partition_key IS NOT NULL') + ->innerJoin( + $this->tableName, + sprintf( + '(SELECT subscription_id, MAX(message_index) AS last_index FROM %s GROUP BY subscription_id)', + $this->inboxTableName, + ), + 'pending', + sprintf( + 'pending.subscription_id = %1$s.id AND pending.last_index > %1$s.position', + $this->tableName, + ), + ); + } + /** @var list $result */ $result = $qb->fetchAllAssociative(); @@ -115,6 +157,14 @@ public function claim(string $id, SubscriptionCriteria $criteria): Subscription| $this->applyCriteria($qb, $criteria); + if ($criteria->pendingPartitionsOnly) { + $qb->andWhere(sprintf( + '%1$s.partition_key IS NULL OR EXISTS (SELECT 1 FROM %2$s WHERE %2$s.subscription_id = %1$s.id AND %2$s.message_index > %1$s.position)', + $this->tableName, + $this->inboxTableName, + )); + } + if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { $qb->forUpdate(ConflictResolutionMode::SKIP_LOCKED); } @@ -163,19 +213,11 @@ private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria) ); } - if ($criteria->partitions !== null) { - $qb->andWhere($criteria->partitions ? 'partition_key IS NOT NULL' : 'partition_key IS NULL'); - } - - if (!$criteria->pendingPartitionsOnly) { + if ($criteria->partitions === null) { return; } - $qb->andWhere(sprintf( - '%1$s.partition_key IS NULL OR EXISTS (SELECT 1 FROM %2$s WHERE %2$s.subscription_id = %1$s.id AND %2$s.message_index > %1$s.position)', - $this->tableName, - $this->inboxTableName, - )); + $qb->andWhere($criteria->partitions ? 'partition_key IS NOT NULL' : 'partition_key IS NULL'); } public function add(Subscription $subscription): void diff --git a/tests/Benchmark/BasicImplementation/Projection/CountingProjector.php b/tests/Benchmark/BasicImplementation/Projection/CountingProjector.php new file mode 100644 index 000000000..387673f71 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Projection/CountingProjector.php @@ -0,0 +1,29 @@ +handled++; + } + + #[Subscribe(NameChanged::class)] + public function onNameChanged(NameChanged $event): void + { + $this->handled++; + } +} diff --git a/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php b/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php new file mode 100644 index 000000000..d597a7a00 --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php @@ -0,0 +1,38 @@ +header(StreamNameHeader::class)->streamName; + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->handled++; + } + + #[Subscribe(NameChanged::class)] + public function onNameChanged(NameChanged $event): void + { + $this->handled++; + } +} diff --git a/tests/Benchmark/PartitionedSubscriptionEngineBench.php b/tests/Benchmark/PartitionedSubscriptionEngineBench.php new file mode 100644 index 000000000..7cfab94ca --- /dev/null +++ b/tests/Benchmark/PartitionedSubscriptionEngineBench.php @@ -0,0 +1,224 @@ + */ + private array $profileIds = []; + + /** @return Generator */ + public function provideAggregates(): Generator + { + $aggregates = getenv('BENCH_PARTITION_AGGREGATES'); + + foreach (explode(',', is_string($aggregates) ? $aggregates : '1000') as $count) { + yield $count . ' aggregates' => ['aggregates' => (int)$count]; + } + } + + /** @param array{aggregates: int} $params */ + public function setUpPartitioned(array $params): void + { + $this->setUp($params['aggregates'], new PartitionedCountingProjector()); + } + + /** @param array{aggregates: int} $params */ + public function setUpPlain(array $params): void + { + $this->setUp($params['aggregates'], new CountingProjector()); + } + + /** @param array{aggregates: int} $params */ + public function setUpPartitionedCaughtUp(array $params): void + { + $this->setUpPartitioned($params); + $this->catchUp($params['aggregates'] * 2); + $this->appendNameChanges(); + } + + /** @param array{aggregates: int} $params */ + public function setUpPlainCaughtUp(array $params): void + { + $this->setUpPlain($params); + $this->catchUp($params['aggregates'] * 2); + $this->appendNameChanges(); + } + + /** + * Processes every event from the beginning, so the partitions are created on the way. + * + * @param array{aggregates: int} $params + */ + #[Bench\BeforeMethods('setUpPartitioned')] + #[Bench\Revs(1)] + #[Bench\Iterations(1)] + public function benchCatchUpPartitioned(array $params): void + { + $this->catchUp($params['aggregates'] * 2); + } + + /** @param array{aggregates: int} $params */ + #[Bench\BeforeMethods('setUpPlain')] + #[Bench\Revs(1)] + #[Bench\Iterations(1)] + public function benchCatchUpPlain(array $params): void + { + $this->catchUp($params['aggregates'] * 2); + } + + /** + * Everything is processed, then a few aggregates get new events: the cost of the daily business. + * + * @param array{aggregates: int} $params + */ + #[Bench\BeforeMethods('setUpPartitionedCaughtUp')] + #[Bench\Revs(1)] + #[Bench\Iterations(3)] + public function benchIncrementalPartitioned(array $params): void + { + $this->catchUp($params['aggregates'] * 2 + self::INCREMENTAL_EVENTS); + } + + /** @param array{aggregates: int} $params */ + #[Bench\BeforeMethods('setUpPlainCaughtUp')] + #[Bench\Revs(1)] + #[Bench\Iterations(3)] + public function benchIncrementalPlain(array $params): void + { + $this->catchUp($params['aggregates'] * 2 + self::INCREMENTAL_EVENTS); + } + + private function setUp(int $aggregates, PartitionedCountingProjector|CountingProjector $projector): void + { + $connection = DbalManager::createConnection(); + + $this->store = new StreamDoctrineDbalStore( + $connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/BasicImplementation/Events']), + ); + + $subscriptionStore = new DoctrineSubscriptionStore($connection); + + (new DoctrineSchemaDirector( + $connection, + new ChainDoctrineSchemaConfigurator([$this->store, $subscriptionStore]), + ))->create(); + + $this->profileIds = []; + + for ($i = 0; $i < $aggregates; $i++) { + $this->profileIds[] = ProfileId::generate(); + } + + $this->save(array_map( + static fn (ProfileId $id): Message => self::message(new ProfileCreated($id, 'Peter', null), $id, 1), + $this->profileIds, + )); + + $this->save(array_map( + static fn (ProfileId $id): Message => self::message(new NameChanged($id, 'Paul'), $id, 2), + $this->profileIds, + )); + + $this->projector = $projector; + $subscriberRepository = new MetadataSubscriberAccessorRepository([$projector]); + + $this->engine = new ThrowOnErrorSubscriptionEngine(new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($this->store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + )); + + $this->engine->execute(new Setup(skipBooting: true)); + } + + private function appendNameChanges(): void + { + $this->save(array_map( + static fn (ProfileId $id): Message => self::message(new NameChanged($id, 'Mary'), $id, 3), + array_slice($this->profileIds, 0, self::INCREMENTAL_EVENTS), + )); + } + + /** Runs until all events are handled, like a worker would. */ + private function catchUp(int $expectedHandled): void + { + for ($i = 0; $i < 10_000; $i++) { + $result = $this->engine->execute(new Run()); + + if ($result instanceof ProcessedResult && $result->finished) { + break; + } + } + + if ($this->projector->handled !== $expectedHandled) { + throw new RuntimeException('Expected ' . $expectedHandled . ' handled events, got ' . $this->projector->handled); + } + } + + /** @param list $messages */ + private function save(array $messages): void + { + foreach (array_chunk($messages, 1000) as $chunk) { + $this->store->save(...$chunk); + } + } + + /** @param positive-int $playhead */ + private static function message(object $event, ProfileId $id, int $playhead): Message + { + return Message::create($event) + ->withHeader(new StreamNameHeader('profile-' . $id->toString())) + ->withHeader(new PlayheadHeader($playhead)); + } +} diff --git a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php index aabb0f35a..c39553d14 100644 --- a/tests/Integration/Store/StreamDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/StreamDoctrineDbalStoreTest.php @@ -22,6 +22,7 @@ use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\TagCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; @@ -1247,4 +1248,33 @@ private function skipIfNoLockTimeout(): void $this->markTestSkipped('PostgreSQL does lock indefinitely'); } + + public function testLoadWithIndexCriterion(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $messages = []; + + for ($playhead = 1; $playhead <= 5; $playhead++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'name-' . $playhead)) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader($playhead)); + } + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new IndexCriterion(4, 2))); + + self::assertSame([2, 4], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } } diff --git a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php index a4b7cd8b1..7ae6608b4 100644 --- a/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php +++ b/tests/Integration/Store/TaggableDoctrineDbalStoreTest.php @@ -26,6 +26,7 @@ use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\TagCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; @@ -1961,4 +1962,33 @@ private function skipIfNoLockTimeout(): void $this->markTestSkipped('PostgreSQL does lock indefinitely'); } + + public function testLoadWithIndexCriterion(): void + { + $profileId = ProfileId::generate(); + $streamName = sprintf('profile-%s', $profileId->toString()); + + $messages = []; + + for ($playhead = 1; $playhead <= 5; $playhead++) { + $messages[] = Message::create(new ProfileCreated($profileId, 'name-' . $playhead)) + ->withHeader(new StreamNameHeader($streamName)) + ->withHeader(new PlayheadHeader($playhead)); + } + + $this->store->save(...$messages); + + $stream = null; + + try { + $stream = $this->store->load(criteria: new Criteria(new IndexCriterion(4, 2))); + + self::assertSame([2, 4], array_map( + static fn (Message $message) => $message->header(PlayheadHeader::class)->playhead, + iterator_to_array($stream, false), + )); + } finally { + $stream?->close(); + } + } } diff --git a/tests/Unit/Store/InMemoryStoreTest.php b/tests/Unit/Store/InMemoryStoreTest.php index a6ffd17c8..9712ddb07 100644 --- a/tests/Unit/Store/InMemoryStoreTest.php +++ b/tests/Unit/Store/InMemoryStoreTest.php @@ -15,6 +15,7 @@ use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromPlayheadCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Criteria\ToIndexCriterion; use Patchlevel\EventSourcing\Store\Header\EventIdHeader; @@ -951,4 +952,26 @@ private function message(object $event, int $index, array $tags = []): Message return $message; } + + public function testLoadWithIndexCriterion(): void + { + $messages = []; + + for ($index = 1; $index <= 4; $index++) { + $messages[] = (new Message(new ProfileVisited(ProfileId::fromString((string)$index)))) + ->withHeader(new StreamNameHeader('foo-1')) + ->withHeader(new PlayheadHeader($index)) + ->withHeader(new IndexHeader($index)); + } + + $store = new InMemoryStore($messages); + + $loaded = $store->load(new Criteria(new IndexCriterion(3, 1)))->toList(); + + self::assertSame( + [1, 3], + [$loaded[0]->header(IndexHeader::class)->index, $loaded[1]->header(IndexHeader::class)->index], + ); + self::assertCount(2, $loaded); + } } diff --git a/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php b/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php index d7a684758..fbc20ba18 100644 --- a/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php +++ b/tests/Unit/Subscription/Engine/EventFilteredStoreMessageLoaderTest.php @@ -12,6 +12,7 @@ use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\EventsCriterion; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; @@ -147,4 +148,24 @@ public function testLastIndex(): void self::assertSame(5, $loader->lastIndex()); } + + public function testLoadIndices(): void + { + $stream = new Stream(); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria(new IndexCriterion(3, 7))) + ->willReturn($stream); + + $loader = new EventFilteredStoreMessageLoader( + $store, + new AttributeEventMetadataFactory(), + $this->createMock(SubscriberAccessorRepository::class), + ); + + self::assertSame($stream, $loader->loadIndices([3, 7])); + } } diff --git a/tests/Unit/Subscription/Engine/GapResolverStoreMessageLoaderTest.php b/tests/Unit/Subscription/Engine/GapResolverStoreMessageLoaderTest.php index e18e166d6..5eacdd75e 100644 --- a/tests/Unit/Subscription/Engine/GapResolverStoreMessageLoaderTest.php +++ b/tests/Unit/Subscription/Engine/GapResolverStoreMessageLoaderTest.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Header\RecordedOnHeader; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\GapResolverStoreMessageLoader; @@ -233,4 +234,20 @@ private function assertEqualsStream(array $expected, Stream $actual): void self::assertEquals($expected, $result); } + + public function testLoadIndices(): void + { + $stream = new Stream(); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria(new IndexCriterion(3, 7))) + ->willReturn($stream); + + $loader = new GapResolverStoreMessageLoader($store); + + self::assertSame($stream, $loader->loadIndices([3, 7])); + } } diff --git a/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php b/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php index d5b6797f1..83f59bef6 100644 --- a/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php +++ b/tests/Unit/Subscription/Engine/StoreMessageLoaderTest.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\FromIndexCriterion; +use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Engine\StoreMessageLoader; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; @@ -79,4 +80,20 @@ public function testLastIndexEmptyStream(): void self::assertSame(0, $loader->lastIndex()); } + + public function testLoadIndices(): void + { + $stream = new Stream(); + + $store = $this->createMock(Store::class); + $store + ->expects($this->once()) + ->method('load') + ->with(new Criteria(new IndexCriterion(3, 7))) + ->willReturn($stream); + + $loader = new StoreMessageLoader($store); + + self::assertSame($stream, $loader->loadIndices([3, 7])); + } } From b49360427f8ea7fac1e85e635de748cf2f5cd79f Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 14 Oct 2026 23:13:35 +0200 Subject: [PATCH 03/57] Claim subscriptions in groups and batch the inbox queries Run and Boot take subscriptionsPerClaim (default 1) and partitionsPerClaim (default 100). A group is claimed and processed in one transaction, and partitions and other subscriptions are never mixed. Grouped subscriptions read the stream once from the lowest position, a grouped partition reads only its inbox and gets the full message limit. The inbox API is batch based now: a whole group is claimed with one query, its inboxes are read with one query and cleared with one, and routed messages are inserted with multi row inserts. While catching up, the router loads all partitions of the subscriber once instead of looking them up one by one. --- docs/UPGRADE-4.0.md | 3 +- docs/cli.md | 20 +- docs/subscription.md | 22 +++ .../Command/SubscriptionBootCommand.php | 20 +- .../Command/SubscriptionRunCommand.php | 26 ++- src/Subscription/Engine/Command/Boot.php | 5 + src/Subscription/Engine/Command/Run.php | 5 + .../Engine/Handler/BootHandler.php | 17 +- .../Engine/Handler/RunHandler.php | 17 +- src/Subscription/Engine/PartitionRouter.php | 34 +++- .../Engine/SubscriptionManager.php | 160 ++++++++++++--- .../Engine/SubscriptionRunner.php | 17 +- .../Store/DoctrineSubscriptionStore.php | 182 +++++++++++++----- .../Store/InMemorySubscriptionStore.php | 78 ++++++-- src/Subscription/Store/SubscriptionStore.php | 34 +++- .../DoctrineSubscriptionStoreTest.php | 43 +++-- .../Subscription/GroupedClaimTest.php | 137 +++++++++++++ .../PartitionedSubscriptionTest.php | 49 ++++- .../Subscription/DummySubscriptionStore.php | 30 ++- .../Engine/SubscriptionManagerTest.php | 55 ++++++ .../Store/InMemorySubscriptionStoreTest.php | 43 +++-- 21 files changed, 827 insertions(+), 170 deletions(-) create mode 100644 tests/Integration/Subscription/GroupedClaimTest.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 25f31a38d..bafd1a2eb 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -485,7 +485,8 @@ happens per subscription inside `claim()`. For `Run` and `Boot`, the `limit` used to cap the total number of messages across the shared stream. Because there is no shared stream anymore, `limit` now caps the messages **per subscription**. One `subscription:run` pass therefore processes up to `limit × number of subscriptions` messages. The -CLI default of `message-limit=100` now means "100 per subscription". It also defines the +CLI default of `message-limit=100` now means "100 per subscription". +If several subscriptions are claimed together with `subscriptionsPerClaim`, the limit applies to the whole group. It also defines the checkpoint/lock-hold granularity: a unit of at most `limit` messages commits atomically and releases the lock afterwards. diff --git a/docs/cli.md b/docs/cli.md index d8c00c416..d728ee162 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -170,15 +170,17 @@ After adding a new subscriber, run the setup and boot step and restart the run c The boot and run commands are workers and support the following options: -| Option | Description | -|-------------------------|------------------------------------------------------------------------------------| -| `--run-limit` | Stop the worker after this number of runs. | -| `--message-limit` | How many events are processed per run. | -| `--memory-limit` | Stop the worker if it uses more memory than this, e.g. `256M` or `250MB`. | -| `--time-limit` | Stop the worker after this number of seconds. | -| `--sleep` | How many milliseconds the worker waits between two runs. | -| `--restart-signal-file` | Stop the worker when this file is touched after it has started. | -| `--heartbeat-file` | Touch this file on start and after every run, and remove it when the worker stops. | +| Option | Description | +|-----------------------------|----------------------------------------------------------------------------------------------------------------| +| `--run-limit` | Stop the worker after this number of runs. | +| `--message-limit` | How many events are processed per run. | +| `--memory-limit` | Stop the worker if it uses more memory than this, e.g. `256M` or `250MB`. | +| `--time-limit` | Stop the worker after this number of seconds. | +| `--sleep` | How many milliseconds the worker waits between two runs. | +| `--restart-signal-file` | Stop the worker when this file is touched after it has started. | +| `--heartbeat-file` | Touch this file on start and after every run, and remove it when the worker stops. | +| `--subscriptions-per-claim` | How many subscriptions are processed together in one transaction (default `1`). | +| `--partitions-per-claim` | How many [partitions](subscription.md#partitioning) are processed together in one transaction (default `100`). | ```bash bin/console event-sourcing:subscription:run --memory-limit=250MB --time-limit=3600 diff --git a/docs/subscription.md b/docs/subscription.md index c50e3af80..f73a5f5fd 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1619,6 +1619,28 @@ The per-subscription order is always preserved. There is no global ordering acro subscriptions, but since subscriptions are independent of each other this does not matter. To process a single subscriber in parallel, split it into [partitions](#partitioning). +### Processing several subscriptions together + +By default every subscription is claimed and processed in its own transaction. +With `subscriptionsPerClaim`, several subscriptions are processed together: the stream is read once from the lowest +position, and the group is committed in one transaction. +Partitions are claimed in groups of 100 by default, because they only read their inbox and are often many. + +```php +use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; + +/** @var SubscriptionEngine $subscriptionEngine */ +$subscriptionEngine->execute(new Run(limit: 100, subscriptionsPerClaim: 10, partitionsPerClaim: 200)); +``` +An error still only stops the subscription it happened in, the others of the group are committed. +Partitions and other subscriptions are never mixed in one group. + +:::warning +A group holds the locks of all its subscriptions until it is committed. +Large groups make the work spread worse over several workers, and the `limit` applies to the whole group. +::: + :::note This requires a database that supports `SKIP LOCKED` (PostgreSQL and MySQL do). SQLite serializes writes anyway and simply ignores the clause, so a single worker is used there. diff --git a/src/Console/Command/SubscriptionBootCommand.php b/src/Console/Command/SubscriptionBootCommand.php index c5a2df405..7455b871d 100644 --- a/src/Console/Command/SubscriptionBootCommand.php +++ b/src/Console/Command/SubscriptionBootCommand.php @@ -50,6 +50,20 @@ public function configure(): void 'How many messages should be consumed in one run', 1000, ) + ->addOption( + 'subscriptions-per-claim', + null, + InputOption::VALUE_REQUIRED, + 'How many subscriptions are claimed and processed together, fewer spread better over several workers', + 1, + ) + ->addOption( + 'partitions-per-claim', + null, + InputOption::VALUE_REQUIRED, + 'How many partitions are claimed and processed together', + 100, + ) ->addOption( 'memory-limit', null, @@ -93,6 +107,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $runLimit = InputHelper::nullablePositiveInt($input->getOption('run-limit')); $messageLimit = InputHelper::nullablePositiveInt($input->getOption('message-limit')); + $subscriptionsPerClaim = InputHelper::positiveInt($input->getOption('subscriptions-per-claim')); + $partitionsPerClaim = InputHelper::positiveInt($input->getOption('partitions-per-claim')); $memoryLimit = InputHelper::nullableString($input->getOption('memory-limit')); $timeLimit = InputHelper::nullablePositiveInt($input->getOption('time-limit')); $sleep = InputHelper::positiveIntOrZero($input->getOption('sleep')); @@ -114,11 +130,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int $finished = false; $worker = DefaultWorker::create( - function (Closure $stop) use ($criteria, $messageLimit, &$finished): bool { + function (Closure $stop) use ($criteria, $messageLimit, $subscriptionsPerClaim, $partitionsPerClaim, &$finished): bool { $result = $this->engine->execute(new Boot( $criteria->ids, $criteria->groups, $messageLimit, + $subscriptionsPerClaim, + $partitionsPerClaim, )); if (!$result instanceof ProcessedResult) { diff --git a/src/Console/Command/SubscriptionRunCommand.php b/src/Console/Command/SubscriptionRunCommand.php index 862f3e9c7..fdcecd1ee 100644 --- a/src/Console/Command/SubscriptionRunCommand.php +++ b/src/Console/Command/SubscriptionRunCommand.php @@ -52,6 +52,20 @@ protected function configure(): void 'How many messages should be consumed in one run', 100, ) + ->addOption( + 'subscriptions-per-claim', + null, + InputOption::VALUE_REQUIRED, + 'How many subscriptions are claimed and processed together, fewer spread better over several workers', + 1, + ) + ->addOption( + 'partitions-per-claim', + null, + InputOption::VALUE_REQUIRED, + 'How many partitions are claimed and processed together', + 100, + ) ->addOption( 'memory-limit', null, @@ -95,6 +109,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int { $runLimit = InputHelper::nullablePositiveInt($input->getOption('run-limit')); $messageLimit = InputHelper::nullablePositiveInt($input->getOption('message-limit')); + $subscriptionsPerClaim = InputHelper::positiveInt($input->getOption('subscriptions-per-claim')); + $partitionsPerClaim = InputHelper::positiveInt($input->getOption('partitions-per-claim')); $memoryLimit = InputHelper::nullableString($input->getOption('memory-limit')); $timeLimit = InputHelper::nullablePositiveInt($input->getOption('time-limit')); $sleep = InputHelper::positiveIntOrZero($input->getOption('sleep')); @@ -108,8 +124,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int $logger = new ConsoleLogger($output); $worker = DefaultWorker::create( - function () use ($criteria, $messageLimit, $sleep): bool { - $result = $this->engine->execute(new Run($criteria->ids, $criteria->groups, $messageLimit)); + function () use ($criteria, $messageLimit, $sleep, $subscriptionsPerClaim, $partitionsPerClaim): bool { + $result = $this->engine->execute(new Run( + $criteria->ids, + $criteria->groups, + $messageLimit, + $subscriptionsPerClaim, + $partitionsPerClaim, + )); if ($result instanceof ProcessedResult && !$result->finished) { return true; diff --git a/src/Subscription/Engine/Command/Boot.php b/src/Subscription/Engine/Command/Boot.php index acf6be1cf..2a3e2d374 100644 --- a/src/Subscription/Engine/Command/Boot.php +++ b/src/Subscription/Engine/Command/Boot.php @@ -10,11 +10,16 @@ final class Boot extends Command * @param list|null $ids * @param list|null $groups * @param positive-int|null $limit + * @param positive-int $subscriptionsPerClaim how many subscriptions are claimed and processed together + * in one transaction, they read the stream once from the lowest position + * @param positive-int $partitionsPerClaim how many partitions are claimed and processed together in one transaction */ public function __construct( array|null $ids = null, array|null $groups = null, public readonly int|null $limit = null, + public readonly int $subscriptionsPerClaim = 1, + public readonly int $partitionsPerClaim = 100, ) { parent::__construct($ids, $groups); } diff --git a/src/Subscription/Engine/Command/Run.php b/src/Subscription/Engine/Command/Run.php index bfd533f66..e973c2fc3 100644 --- a/src/Subscription/Engine/Command/Run.php +++ b/src/Subscription/Engine/Command/Run.php @@ -10,11 +10,16 @@ final class Run extends Command * @param list|null $ids * @param list|null $groups * @param positive-int|null $limit + * @param positive-int $subscriptionsPerClaim how many subscriptions are claimed and processed together + * in one transaction, they read the stream once from the lowest position + * @param positive-int $partitionsPerClaim how many partitions are claimed and processed together in one transaction */ public function __construct( array|null $ids = null, array|null $groups = null, public readonly int|null $limit = null, + public readonly int $subscriptionsPerClaim = 1, + public readonly int $partitionsPerClaim = 100, ) { parent::__construct($ids, $groups); } diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 210f6a85a..bc011ec54 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -15,6 +15,8 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Throwable; +use function array_map; + /** * @internal * @@ -31,21 +33,26 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { /** @var list $results */ - $results = $this->subscriptionManager->forEachClaimed( + $results = $this->subscriptionManager->forEachClaimedGroup( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, status: [Status::Booting], ), - fn (Subscription $subscription): ProcessedResult => $this->runner->process( - [$subscription], + $command->partitionsPerClaim, + $command->subscriptionsPerClaim, + fn (array $subscriptions): ProcessedResult => $this->runner->process( + $subscriptions, $command->limit, boot: true, ), - static fn (Subscription $subscription, Throwable $e): ProcessedResult => new ProcessedResult( + static fn (array $subscriptions, Throwable $e): ProcessedResult => new ProcessedResult( 0, false, - [new Error($subscription->id(), $e->getMessage(), $e)], + array_map( + static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), + $subscriptions, + ), ), ); diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index b3c4e50ac..afd58bc00 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -15,6 +15,8 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Throwable; +use function array_map; + /** * @internal * @@ -31,21 +33,26 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { /** @var list $results */ - $results = $this->subscriptionManager->forEachClaimed( + $results = $this->subscriptionManager->forEachClaimedGroup( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, status: [Status::Active], pendingPartitionsOnly: true, ), - fn (Subscription $subscription): ProcessedResult => $this->runner->process( - [$subscription], + $command->partitionsPerClaim, + $command->subscriptionsPerClaim, + fn (array $subscriptions): ProcessedResult => $this->runner->process( + $subscriptions, $command->limit, ), - static fn (Subscription $subscription, Throwable $e): ProcessedResult => new ProcessedResult( + static fn (array $subscriptions, Throwable $e): ProcessedResult => new ProcessedResult( 0, false, - [new Error($subscription->id(), $e->getMessage(), $e)], + array_map( + static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), + $subscriptions, + ), ), ); diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index 7610aade2..bf68a1a77 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -29,9 +29,18 @@ final class PartitionRouter /** Maximum length of a subscription id in bytes, the id of a partition has to fit into it. */ public const MAX_ID_LENGTH = 255; + /** After that many lookups of single partitions, all partitions of the subscriber are loaded at once. */ + private const SINGLE_LOOKUPS = 50; + /** @var array partition ids known to exist */ private array $knownPartitions = []; + /** @var array main subscription id => single lookups so far */ + private array $lookups = []; + + /** @var array main subscriptions whose partitions are all loaded */ + private array $loaded = []; + private bool $routed = false; public function __construct( @@ -46,7 +55,7 @@ public function route(int $index, Message $message, Subscription $main, Metadata $partitionKey = $this->partitionKey($message, $main, $subscriber); $partitionId = Subscription::partitionId($main->id(), $partitionKey); - if (!$this->exists($partitionId)) { + if (!$this->exists($main, $partitionId)) { $this->subscriptionManager->add(Subscription::partition($main, $partitionKey, $index - 1)); $this->knownPartitions[$partitionId] = true; @@ -94,15 +103,32 @@ private function partitionKey(Message $message, Subscription $main, MetadataSubs } /** - * Looks up only the partitions the routed messages belong to, so routing a few messages stays cheap - * no matter how many partitions the subscriber has. + * Looks up only the partitions the routed messages belong to, so routing a few messages stays cheap no matter how + * many partitions the subscriber has. When many messages are routed, e.g. while catching up, all partitions are + * loaded with one query instead. */ - private function exists(string $partitionId): bool + private function exists(Subscription $main, string $partitionId): bool { if (isset($this->knownPartitions[$partitionId])) { return true; } + if (isset($this->loaded[$main->id()])) { + return false; + } + + $this->lookups[$main->id()] = ($this->lookups[$main->id()] ?? 0) + 1; + + if ($this->lookups[$main->id()] > self::SINGLE_LOOKUPS) { + foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { + $this->knownPartitions[$partition->id()] = true; + } + + $this->loaded[$main->id()] = true; + + return isset($this->knownPartitions[$partitionId]); + } + if ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { return false; } diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index f60d4a03d..b2004a4d0 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -13,6 +13,9 @@ use SplObjectStorage; use Throwable; +use function array_map; +use function count; +use function implode; use function max; use function sprintf; @@ -28,7 +31,7 @@ final class SubscriptionManager /** @var SplObjectStorage */ private SplObjectStorage $forRemove; - /** @var array> */ + /** @var array> */ private array $forInbox = []; /** @var array */ @@ -53,38 +56,82 @@ public function __construct( */ public function forEachClaimed(SubscriptionCriteria $criteria, Closure $closure, Closure|null $onError = null): array { - $snapshot = $this->subscriptionStore->find($criteria); + $groupOnError = null; + if ($onError !== null) { + $groupOnError = static function (array $subscriptions, Throwable $e) use ($onError): mixed { + /** @var non-empty-list $subscriptions */ + return $onError($subscriptions[0], $e); + }; + } + + return $this->forEachClaimedGroup( + $criteria, + 1, + 1, + static function (array $subscriptions) use ($closure): mixed { + /** @var non-empty-list $subscriptions */ + return $closure($subscriptions[0]); + }, + $groupOnError, + ); + } + + /** + * Claims subscriptions in groups in one transaction and passes them to the closure together. Partitions and other + * subscriptions are never mixed, because the others have to read the whole stream while partitions only read their + * inbox. A subscription that is locked by another worker is left out of its group. + * If the closure throws, every subscription of the group goes into the error status. + * + * @param positive-int $partitionsPerClaim + * @param positive-int $subscriptionsPerClaim + * @param Closure(non-empty-list):T $closure + * @param Closure(non-empty-list, Throwable):T $onError + * + * @return list + * + * @template T + */ + public function forEachClaimedGroup( + SubscriptionCriteria $criteria, + int $partitionsPerClaim, + int $subscriptionsPerClaim, + Closure $closure, + Closure|null $onError = null, + ): array { $results = []; + $groups = $this->groups($this->subscriptionStore->find($criteria), $partitionsPerClaim, $subscriptionsPerClaim); - foreach ($snapshot as $candidate) { + foreach ($groups as $candidates) { $skipped = false; try { $outcome = $this->subscriptionStore->inLock( /** @return T|null */ - function () use ($candidate, $criteria, $closure, $onError, &$skipped): mixed { - $subscription = $this->subscriptionStore->claim($candidate->id(), $criteria); + function () use ($candidates, $criteria, $closure, $onError, &$skipped): mixed { + $subscriptions = $this->claim($candidates, $criteria); - if ($subscription === null) { + if ($subscriptions === []) { $skipped = true; return null; } try { - return $closure($subscription); + return $closure($subscriptions); } catch (TransactionCommitNotPossible $e) { throw $e; } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscription "%s" failed: %s', - $subscription->id(), - $e->getMessage(), - )); - - $subscription->error($e); - $this->update($subscription); + foreach ($subscriptions as $subscription) { + $this->logger?->error(sprintf( + 'Subscription Engine: Subscription "%s" failed: %s', + $subscription->id(), + $e->getMessage(), + )); + + $subscription->error($e); + $this->update($subscription); + } if ($onError === null) { $skipped = true; @@ -92,7 +139,7 @@ function () use ($candidate, $criteria, $closure, $onError, &$skipped): mixed { return null; } - return $onError($subscription, $e); + return $onError($subscriptions, $e); } finally { $this->flush(); } @@ -103,7 +150,7 @@ function () use ($candidate, $criteria, $closure, $onError, &$skipped): mixed { $this->logger?->warning(sprintf( 'Subscription Engine: Subscription "%s" hit a transient error and will be retried on the next run: %s', - $candidate->id(), + implode('", "', array_map(static fn (Subscription $s): string => $s->id(), $candidates)), $e->getMessage(), )); @@ -122,6 +169,64 @@ function () use ($candidate, $criteria, $closure, $onError, &$skipped): mixed { return $results; } + /** + * @param non-empty-list $candidates + * + * @return list + */ + private function claim(array $candidates, SubscriptionCriteria $criteria): array + { + if (count($candidates) === 1) { + $subscription = $this->subscriptionStore->claim($candidates[0]->id(), $criteria); + + return $subscription === null ? [] : [$subscription]; + } + + return $this->subscriptionStore->claimAll( + array_map(static fn (Subscription $subscription): string => $subscription->id(), $candidates), + $criteria, + ); + } + + /** + * Keeps the order of the snapshot as far as possible, a group is closed as soon as it is full. + * + * @param list $subscriptions + * @param positive-int $partitionsPerClaim + * @param positive-int $subscriptionsPerClaim + * + * @return list> + */ + private function groups(array $subscriptions, int $partitionsPerClaim, int $subscriptionsPerClaim): array + { + $groups = []; + $open = ['partitions' => [], 'subscriptions' => []]; + + foreach ($subscriptions as $subscription) { + $kind = $subscription->isPartition() ? 'partitions' : 'subscriptions'; + $size = $subscription->isPartition() ? $partitionsPerClaim : $subscriptionsPerClaim; + + $open[$kind][] = $subscription; + + if (count($open[$kind]) < $size) { + continue; + } + + $groups[] = $open[$kind]; + $open[$kind] = []; + } + + foreach ($open as $group) { + if ($group === []) { + continue; + } + + $groups[] = $group; + } + + return $groups; + } + /** @return list */ public function find(SubscriptionCriteria $criteria): array { @@ -162,13 +267,18 @@ public function removeFromInbox(string $subscriptionId, int $toIndex): void } /** - * @param positive-int $limit + * @param array $afterIndex subscription id => index after which the inbox is read + * @param positive-int $limit * - * @return list + * @return array> */ - public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + public function inbox(array $afterIndex, int $limit): array { - return $this->subscriptionStore->inbox($subscriptionId, $afterIndex, $limit); + if ($afterIndex === []) { + return []; + } + + return $this->subscriptionStore->inbox($afterIndex, $limit); } public function flush(): void @@ -201,12 +311,12 @@ public function flush(): void $this->subscriptionStore->remove($subscription); } - foreach ($this->forInbox as $subscriptionId => $indices) { - $this->subscriptionStore->addToInbox($subscriptionId, $indices); + if ($this->forInbox !== []) { + $this->subscriptionStore->addToInbox($this->forInbox); } - foreach ($this->forInboxRemove as $subscriptionId => $toIndex) { - $this->subscriptionStore->removeFromInbox($subscriptionId, $toIndex); + if ($this->forInboxRemove !== []) { + $this->subscriptionStore->removeFromInbox($this->forInboxRemove); } $this->clearPending(); diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 296997bb2..e4e0b406b 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -128,7 +128,8 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals } } - if ($limit !== null && $messageCounter >= $limit) { + // the inbox of every partition is already limited, a shared limit would starve the others + if ($indices === null && $limit !== null && $messageCounter >= $limit) { $this->logger?->info(sprintf( 'Subscription Engine: Message limit (%d) reached, finish processing.', $limit, @@ -180,16 +181,22 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals */ private function loadInboxes(SubscriptionCollection $members, int $limit): array { - $inboxes = []; + $afterIndex = []; foreach ($members as $member) { if (!$member->isPartition()) { continue; } - $inboxes[$member->id()] = new PartitionInbox( - $this->subscriptionManager->inbox($member->id(), $member->position() ?? -1, $limit), - ); + $afterIndex[$member->id()] = $member->position() ?? -1; + } + + // one query for the inboxes of the whole group + $indices = $this->subscriptionManager->inbox($afterIndex, $limit); + $inboxes = []; + + foreach ($afterIndex as $subscriptionId => $index) { + $inboxes[$subscriptionId] = new PartitionInbox($indices[$subscriptionId] ?? []); } return $inboxes; diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 2b672372b..b50873cc9 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -11,6 +11,7 @@ use Doctrine\DBAL\Exception\DriverException; use Doctrine\DBAL\Exception\RetryableException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; +use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; @@ -27,8 +28,13 @@ use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Psr\Clock\ClockInterface; +use function array_chunk; +use function array_fill; use function array_map; +use function array_merge; use function assert; +use function count; +use function implode; use function json_decode; use function json_encode; use function serialize; @@ -55,6 +61,9 @@ */ final class DoctrineSubscriptionStore implements SubscriptionStore, DoctrineSchemaConfigurator { + /** How many inbox rows or conditions go into one statement, far below the parameter limits of the databases. */ + private const INBOX_CHUNK = 500; + private readonly string $inboxTableName; public function __construct( @@ -156,14 +165,7 @@ public function claim(string $id, SubscriptionCriteria $criteria): Subscription| ->setParameter('id', $id); $this->applyCriteria($qb, $criteria); - - if ($criteria->pendingPartitionsOnly) { - $qb->andWhere(sprintf( - '%1$s.partition_key IS NULL OR EXISTS (SELECT 1 FROM %2$s WHERE %2$s.subscription_id = %1$s.id AND %2$s.message_index > %1$s.position)', - $this->tableName, - $this->inboxTableName, - )); - } + $this->applyPending($qb, $criteria); if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { $qb->forUpdate(ConflictResolutionMode::SKIP_LOCKED); @@ -179,6 +181,50 @@ public function claim(string $id, SubscriptionCriteria $criteria): Subscription| return $this->createSubscription($result); } + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claimAll(array $ids, SubscriptionCriteria $criteria): array + { + $qb = $this->connection->createQueryBuilder() + ->select('*') + ->from($this->tableName) + ->where('id IN (:claim_ids)') + ->setParameter('claim_ids', $ids, ArrayParameterType::STRING) + ->orderBy('id'); + + $this->applyCriteria($qb, $criteria); + $this->applyPending($qb, $criteria); + + if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $qb->forUpdate(ConflictResolutionMode::SKIP_LOCKED); + } + + /** @var list $result */ + $result = $qb->fetchAllAssociative(); + + return array_map( + fn (array $data) => $this->createSubscription($data), + $result, + ); + } + + /** A partition is only claimed if its inbox has messages after its position. */ + private function applyPending(QueryBuilder $qb, SubscriptionCriteria $criteria): void + { + if (!$criteria->pendingPartitionsOnly) { + return; + } + + $qb->andWhere(sprintf( + '%1$s.partition_key IS NULL OR EXISTS (SELECT 1 FROM %2$s WHERE %2$s.subscription_id = %1$s.id AND %2$s.message_index > %1$s.position)', + $this->tableName, + $this->inboxTableName, + )); + } + private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria): void { if ($criteria->ids !== null) { @@ -307,57 +353,103 @@ public function remove(Subscription $subscription): void $this->connection->delete($this->inboxTableName, ['subscription_id' => $subscription->id()]); } - /** @param list $indices */ - public function addToInbox(string $subscriptionId, array $indices): void + /** @param array> $indices */ + public function addToInbox(array $indices): void { - foreach ($indices as $index) { - $this->connection->insert( - $this->inboxTableName, - [ - 'subscription_id' => $subscriptionId, - 'message_index' => $index, - ], + $rows = []; + + foreach ($indices as $subscriptionId => $messageIndices) { + foreach ($messageIndices as $messageIndex) { + $rows[] = [$subscriptionId, $messageIndex]; + } + } + + foreach (array_chunk($rows, self::INBOX_CHUNK) as $chunk) { + $this->connection->executeStatement( + sprintf( + 'INSERT INTO %s (subscription_id, message_index) VALUES %s', + $this->inboxTableName, + implode(', ', array_fill(0, count($chunk), '(?, ?)')), + ), + array_merge(...$chunk), + array_merge(...array_fill(0, count($chunk), [ParameterType::STRING, ParameterType::INTEGER])), ); } } /** - * @param positive-int $limit + * @param array $afterIndex + * @param positive-int $limit * - * @return list + * @return array> */ - public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + public function inbox(array $afterIndex, int $limit): array { - $sql = $this->connection->createQueryBuilder() - ->select('message_index') - ->from($this->inboxTableName) - ->where('subscription_id = :subscription_id') - ->andWhere('message_index > :after_index') - ->orderBy('message_index') - ->setMaxResults($limit) - ->getSQL(); + $inbox = []; + + foreach (array_chunk($afterIndex, self::INBOX_CHUNK, true) as $chunk) { + $conditions = []; + $parameters = []; + $types = []; + + foreach ($chunk as $subscriptionId => $index) { + $conditions[] = '(subscription_id = ? AND message_index > ?)'; + $parameters[] = (string)$subscriptionId; + $parameters[] = $index; + $types[] = ParameterType::STRING; + $types[] = ParameterType::INTEGER; + } - /** @var list $indices */ - $indices = $this->connection->fetchFirstColumn($sql, [ - 'subscription_id' => $subscriptionId, - 'after_index' => $afterIndex, - ]); + // sqlite compares the untyped row number with a string parameter as text, so the types are explicit + $parameters[] = $limit; + $types[] = ParameterType::INTEGER; + + // the row number limits the messages per subscription, so one query serves a whole group of partitions + /** @var list $rows */ + $rows = $this->connection->fetchAllNumeric( + sprintf( + 'SELECT subscription_id, message_index FROM ( + SELECT subscription_id, message_index, ROW_NUMBER() OVER (PARTITION BY subscription_id ORDER BY message_index) AS inbox_position + FROM %s + WHERE %s + ) inbox WHERE inbox_position <= ? ORDER BY subscription_id, message_index', + $this->inboxTableName, + implode(' OR ', $conditions), + ), + $parameters, + $types, + ); + + foreach ($rows as [$subscriptionId, $messageIndex]) { + $inbox[(string)$subscriptionId][] = (int)$messageIndex; + } + } - return array_map(static fn (int|string $index): int => (int)$index, $indices); + return $inbox; } - public function removeFromInbox(string $subscriptionId, int $toIndex): void + /** @param array $toIndex */ + public function removeFromInbox(array $toIndex): void { - $this->connection->executeStatement( - sprintf( - 'DELETE FROM %s WHERE subscription_id = :subscription_id AND message_index <= :to_index', - $this->inboxTableName, - ), - [ - 'subscription_id' => $subscriptionId, - 'to_index' => $toIndex, - ], - ); + foreach (array_chunk($toIndex, self::INBOX_CHUNK, true) as $chunk) { + $conditions = []; + $parameters = []; + $types = []; + + foreach ($chunk as $subscriptionId => $index) { + $conditions[] = '(subscription_id = ? AND message_index <= ?)'; + $parameters[] = (string)$subscriptionId; + $parameters[] = $index; + $types[] = ParameterType::STRING; + $types[] = ParameterType::INTEGER; + } + + $this->connection->executeStatement( + sprintf('DELETE FROM %s WHERE %s', $this->inboxTableName, implode(' OR ', $conditions)), + $parameters, + $types, + ); + } } /** diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index dc991a53f..5646a73f2 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -76,6 +76,28 @@ public function claim(string $id, SubscriptionCriteria $criteria): Subscription| return $subscription; } + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claimAll(array $ids, SubscriptionCriteria $criteria): array + { + $claimed = []; + + foreach ($ids as $id) { + $subscription = $this->claim($id, $criteria); + + if ($subscription === null) { + continue; + } + + $claimed[] = $subscription; + } + + return $claimed; + } + /** * @param Closure():T $closure * @@ -116,7 +138,7 @@ private function matches(Subscription $subscription, SubscriptionCriteria $crite } if ($criteria->pendingPartitionsOnly && $subscription->isPartition()) { - return $this->inbox($subscription->id(), $subscription->position() ?? -1, 1) !== []; + return $this->inbox([$subscription->id() => $subscription->position() ?? -1], 1) !== []; } return true; @@ -149,39 +171,55 @@ public function remove(Subscription $subscription): void unset($this->subscriptions[$subscription->id()], $this->inbox[$subscription->id()]); } - /** @param list $indices */ - public function addToInbox(string $subscriptionId, array $indices): void + /** @param array> $indices */ + public function addToInbox(array $indices): void { - foreach ($indices as $index) { - $this->inbox[$subscriptionId][$index] = true; + foreach ($indices as $subscriptionId => $messageIndices) { + foreach ($messageIndices as $messageIndex) { + $this->inbox[$subscriptionId][$messageIndex] = true; + } } } /** - * @param positive-int $limit + * @param array $afterIndex + * @param positive-int $limit * - * @return list + * @return array> */ - public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + public function inbox(array $afterIndex, int $limit): array { - $indices = array_values(array_filter( - array_keys($this->inbox[$subscriptionId] ?? []), - static fn (int $index): bool => $index > $afterIndex, - )); + $inbox = []; + + foreach ($afterIndex as $subscriptionId => $index) { + $indices = array_values(array_filter( + array_keys($this->inbox[$subscriptionId] ?? []), + static fn (int $messageIndex): bool => $messageIndex > $index, + )); + + if ($indices === []) { + continue; + } - sort($indices); + sort($indices); - return array_slice($indices, 0, $limit); + $inbox[$subscriptionId] = array_slice($indices, 0, $limit); + } + + return $inbox; } - public function removeFromInbox(string $subscriptionId, int $toIndex): void + /** @param array $toIndex */ + public function removeFromInbox(array $toIndex): void { - foreach (array_keys($this->inbox[$subscriptionId] ?? []) as $index) { - if ($index > $toIndex) { - continue; - } + foreach ($toIndex as $subscriptionId => $index) { + foreach (array_keys($this->inbox[$subscriptionId] ?? []) as $messageIndex) { + if ($messageIndex > $index) { + continue; + } - unset($this->inbox[$subscriptionId][$index]); + unset($this->inbox[$subscriptionId][$messageIndex]); + } } } diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index 508bfb267..ad3a6ab6e 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -18,6 +18,16 @@ public function find(SubscriptionCriteria|null $criteria = null): array; /** Claims one subscription via a row lock (SKIP LOCKED); null if held by another worker or no match. */ public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null; + /** + * Claims several subscriptions at once via row locks (SKIP LOCKED), leaving out the ones that are held by another + * worker or no longer match. + * + * @param non-empty-list $ids + * + * @return list + */ + public function claimAll(array $ids, SubscriptionCriteria $criteria): array; + /** @throws SubscriptionAlreadyExists */ public function add(Subscription $subscription): void; @@ -32,24 +42,30 @@ public function update(Subscription $subscription): void; public function remove(Subscription $subscription): void; /** - * Adds message indices to the inbox of a partition. The main subscription of a partitioned subscriber routes every + * Adds message indices to the inboxes of partitions. The main subscription of a partitioned subscriber routes every * message exactly once, the partition then only processes the messages in its inbox. * - * @param list $indices + * @param array> $indices subscription id => message indices */ - public function addToInbox(string $subscriptionId, array $indices): void; + public function addToInbox(array $indices): void; /** - * Returns the indices in the inbox after the given index in ascending order. + * Returns at most $limit indices per subscription after the given index, in ascending order. + * Subscriptions with an empty inbox are left out. * - * @param positive-int $limit + * @param array $afterIndex subscription id => index after which the inbox is read + * @param positive-int $limit * - * @return list + * @return array> */ - public function inbox(string $subscriptionId, int $afterIndex, int $limit): array; + public function inbox(array $afterIndex, int $limit): array; - /** Removes all indices up to and including the given index from the inbox. */ - public function removeFromInbox(string $subscriptionId, int $toIndex): void; + /** + * Removes all indices up to and including the given index from the inboxes. + * + * @param array $toIndex subscription id => index up to which the inbox is cleared + */ + public function removeFromInbox(array $toIndex): void; /** * @param Closure():T $closure diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 3a23130c3..cd21bfd5f 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1131,17 +1131,20 @@ public function testInbox(): void { $store = $this->createStore(); - $store->addToInbox('profile#a', [5, 3, 9]); - $store->addToInbox('profile#b', [4]); + $store->addToInbox(['profile#a' => [5, 3, 9], 'profile#b' => [4]]); - self::assertSame([3, 5, 9], $store->inbox('profile#a', -1, 10)); - self::assertSame([5, 9], $store->inbox('profile#a', 3, 10)); - self::assertSame([3, 5], $store->inbox('profile#a', -1, 2)); + self::assertSame(['profile#a' => [3, 5, 9]], $store->inbox(['profile#a' => -1], 10)); + self::assertSame(['profile#a' => [5, 9]], $store->inbox(['profile#a' => 3], 10)); + self::assertSame( + ['profile#a' => [3, 5], 'profile#b' => [4]], + $store->inbox(['profile#a' => -1, 'profile#b' => -1], 2), + 'the limit applies to every subscription', + ); + self::assertSame([], $store->inbox(['profile#c' => -1], 10), 'an empty inbox is left out'); - $store->removeFromInbox('profile#a', 5); + $store->removeFromInbox(['profile#a' => 5, 'profile#b' => 3]); - self::assertSame([9], $store->inbox('profile#a', -1, 10)); - self::assertSame([4], $store->inbox('profile#b', -1, 10)); + self::assertSame(['profile#a' => [9], 'profile#b' => [4]], $store->inbox(['profile#a' => -1, 'profile#b' => -1], 10)); } public function testPendingPartitionsOnly(): void @@ -1154,8 +1157,7 @@ public function testPendingPartitionsOnly(): void $store->add(Subscription::partition($main, 'processed', 7)); $store->add(Subscription::partition($main, 'empty', 0)); - $store->addToInbox('profile#pending', [4]); - $store->addToInbox('profile#processed', [7]); + $store->addToInbox(['profile#pending' => [4], 'profile#processed' => [7]]); self::assertSame( ['profile', 'profile#pending'], @@ -1175,12 +1177,12 @@ public function testRemoveAlsoRemovesTheInbox(): void $partition = Subscription::partition(new Subscription('profile'), 'a', 0); $store->add($partition); - $store->addToInbox($partition->id(), [1, 2]); + $store->addToInbox([$partition->id() => [1, 2]]); $store->remove($partition); $store->add($partition); - self::assertSame([], $store->inbox($partition->id(), -1, 10)); + self::assertSame([], $store->inbox([$partition->id() => -1], 10)); } private function createStore(): DoctrineSubscriptionStore @@ -1194,4 +1196,21 @@ private function createStore(): DoctrineSubscriptionStore return $store; } + + public function testClaimAll(): void + { + $store = $this->createStore(); + + $store->add(new Subscription('a', status: Status::Active)); + $store->add(new Subscription('b', status: Status::Paused)); + $store->add(new Subscription('c', status: Status::Active)); + + self::assertSame( + ['a', 'c'], + array_map( + static fn (Subscription $s) => $s->id(), + $store->claimAll(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), + ), + ); + } } diff --git a/tests/Integration/Subscription/GroupedClaimTest.php b/tests/Integration/Subscription/GroupedClaimTest.php new file mode 100644 index 000000000..838793f2b --- /dev/null +++ b/tests/Integration/Subscription/GroupedClaimTest.php @@ -0,0 +1,137 @@ +connection = DbalManager::createConnection(); + } + + public function tearDown(): void + { + $this->connection->close(); + + gc_collect_cycles(); + } + + public function testSubscriptionsOfAGroupAreProcessedTogetherAndFailIndependently(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + $clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); + $subscriptionStore = new DoctrineSubscriptionStore($this->connection, $clock); + + (new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([$store, $subscriptionStore]), + ))->create(); + + $first = new #[Projector('first')] + class { + /** @var list */ + public array $names = []; + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->names[] = $event->name; + } + }; + + $failing = new #[Projector('failing')] + class { + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + throw new RuntimeException('boom'); + } + }; + + $second = new #[Projector('second')] + class { + /** @var list */ + public array $names = []; + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->names[] = $event->name; + } + }; + + $subscriberRepository = new MetadataSubscriberAccessorRepository([$first, $failing, $second]); + + $engine = new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy($clock), + ]), + ); + + $repository = (new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ))->get(Profile::class); + + $engine->execute(new Setup(skipBooting: true)); + + $repository->save(Profile::create(ProfileId::generate(), 'John')); + $repository->save(Profile::create(ProfileId::generate(), 'Jane')); + + $result = $engine->execute(new Run(subscriptionsPerClaim: 10)); + + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertSame(2, $result->processedMessages, 'the group reads the stream once'); + self::assertCount(1, $result->errors); + + self::assertSame(['John', 'Jane'], $first->names); + self::assertSame(['John', 'Jane'], $second->names); + + self::assertSame(Status::Active, $subscriptionStore->get('first')->status()); + self::assertSame(Status::Error, $subscriptionStore->get('failing')->status()); + self::assertSame(Status::Active, $subscriptionStore->get('second')->status()); + self::assertSame(2, $subscriptionStore->get('second')->position()); + } +} diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 8bb5db289..18b5064cb 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -92,7 +92,7 @@ public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void self::assertSame(self::MAIN, $partition->subscriberId()); self::assertSame('profile-' . $id, $partition->partitionKey()); self::assertSame(Status::Active, $partition->status()); - self::assertCount(1, $this->subscriptionStore->inbox($partition->id(), -1, 10)); + self::assertCount(1, $this->subscriptionStore->inbox([$partition->id() => -1], 10)[$partition->id()] ?? []); } $this->runUntilFinished($engine); @@ -102,7 +102,7 @@ public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void } foreach ($this->partitions() as $partition) { - self::assertSame([], $this->subscriptionStore->inbox($partition->id(), -1, 10), 'processed messages leave the inbox'); + self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10), 'processed messages leave the inbox'); } } @@ -305,7 +305,7 @@ public function testBatchSubscriberWithPartitions(): void } foreach ($this->partitions(PartitionedBatchProfileProjection::SUBSCRIBER_ID) as $partition) { - self::assertSame([], $this->subscriptionStore->inbox($partition->id(), -1, 10), $partition->id()); + self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10), $partition->id()); } } @@ -365,11 +365,14 @@ private function changeName(ProfileId $profileId, string $name): void $this->repository->save($profile); } - /** @param positive-int|null $limit */ - private function runUntilFinished(SubscriptionEngine $engine, int|null $limit = null): void + /** + * @param positive-int|null $limit + * @param positive-int $partitionsPerClaim + */ + private function runUntilFinished(SubscriptionEngine $engine, int|null $limit = null, int $partitionsPerClaim = 100): void { for ($i = 0; $i < 50; $i++) { - $result = $engine->execute(new Run(limit: $limit)); + $result = $engine->execute(new Run(limit: $limit, partitionsPerClaim: $partitionsPerClaim)); self::assertInstanceOf(ProcessedResult::class, $result); if ($result->finished) { @@ -391,4 +394,38 @@ private function partitions(string $subscriberId = self::MAIN): array return $partitions; } + + public function testEachPartitionInItsOwnClaim(): void + { + $projection = new PartitionedProfileProjection(); + $projection->failingNames = ['broken']; + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + [$brokenId, $healthyId, $otherId] = $this->createProfiles(3, ['broken', 'healthy', 'other']); + + $this->runUntilFinished($engine, partitionsPerClaim: 1); + + self::assertSame(['healthy'], $projection->processed[$healthyId]); + self::assertSame(['other'], $projection->processed[$otherId]); + self::assertArrayNotHasKey($brokenId, $projection->processed); + } + + public function testLimitAppliesToEveryPartitionOfAClaim(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(5); + + $engine->execute(new Run()); // the main subscription routes everything + $engine->execute(new Run(limit: 1)); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i], $projection->processed[$id], 'a limit of 1 still lets every partition of the group process a message'); + } + } } diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 573974ec9..adff0e511 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -83,25 +83,37 @@ public function remove(Subscription $subscription): void $this->removedSubscriptions[] = clone $subscription; } - /** @param list $indices */ - public function addToInbox(string $subscriptionId, array $indices): void + /** @param array> $indices */ + public function addToInbox(array $indices): void { - $this->parentStore->addToInbox($subscriptionId, $indices); + $this->parentStore->addToInbox($indices); } /** - * @param positive-int $limit + * @param array $afterIndex + * @param positive-int $limit * - * @return list + * @return array> */ - public function inbox(string $subscriptionId, int $afterIndex, int $limit): array + public function inbox(array $afterIndex, int $limit): array { - return $this->parentStore->inbox($subscriptionId, $afterIndex, $limit); + return $this->parentStore->inbox($afterIndex, $limit); } - public function removeFromInbox(string $subscriptionId, int $toIndex): void + /** @param array $toIndex */ + public function removeFromInbox(array $toIndex): void { - $this->parentStore->removeFromInbox($subscriptionId, $toIndex); + $this->parentStore->removeFromInbox($toIndex); + } + + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claimAll(array $ids, SubscriptionCriteria $criteria): array + { + return $this->parentStore->claimAll($ids, $criteria); } public function reset(): void diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index 4b0ee2c2b..fffd0f4e5 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -6,6 +6,8 @@ use Closure; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; +use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\TransactionCommitNotPossible; @@ -13,6 +15,10 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use RuntimeException; +use Throwable; + +use function array_map; +use function count; #[CoversClass(SubscriptionManager::class)] final class SubscriptionManagerTest extends TestCase @@ -177,4 +183,53 @@ static function (): never { self::assertSame([], $result); } + + public function testForEachClaimedGroupKeepsPartitionsAndSubscriptionsApart(): void + { + $main = new Subscription('a'); + $store = new InMemorySubscriptionStore([ + $main, + Subscription::partition($main, '1', 0), + Subscription::partition($main, '2', 0), + Subscription::partition($main, '3', 0), + new Subscription('b'), + new Subscription('c'), + ]); + + $manager = new SubscriptionManager($store); + + $groups = $manager->forEachClaimedGroup( + new SubscriptionCriteria(), + 2, + 2, + static fn (array $subscriptions): array => array_map( + static fn (Subscription $subscription): string => $subscription->id(), + $subscriptions, + ), + ); + + self::assertSame([['a#1', 'a#2'], ['a', 'b'], ['a#3'], ['c']], $groups); + } + + public function testForEachClaimedGroupPutsTheWholeGroupIntoError(): void + { + $store = new InMemorySubscriptionStore([ + new Subscription('a', status: Status::Active), + new Subscription('b', status: Status::Active), + ]); + + $manager = new SubscriptionManager($store); + + $results = $manager->forEachClaimedGroup( + new SubscriptionCriteria(), + 1, + 2, + static fn (array $subscriptions): never => throw new RuntimeException('boom'), + static fn (array $subscriptions, Throwable $e): int => count($subscriptions), + ); + + self::assertSame([2], $results); + self::assertSame(Status::Error, $store->get('a')->status()); + self::assertSame(Status::Error, $store->get('b')->status()); + } } diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index 0c085545e..9f4d16afd 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -253,17 +253,20 @@ public function testInbox(): void { $store = new InMemorySubscriptionStore(); - $store->addToInbox('profile#a', [5, 3, 9]); - $store->addToInbox('profile#b', [4]); + $store->addToInbox(['profile#a' => [5, 3, 9], 'profile#b' => [4]]); - self::assertSame([3, 5, 9], $store->inbox('profile#a', -1, 10)); - self::assertSame([5, 9], $store->inbox('profile#a', 3, 10)); - self::assertSame([3, 5], $store->inbox('profile#a', -1, 2)); + self::assertSame(['profile#a' => [3, 5, 9]], $store->inbox(['profile#a' => -1], 10)); + self::assertSame(['profile#a' => [5, 9]], $store->inbox(['profile#a' => 3], 10)); + self::assertSame( + ['profile#a' => [3, 5], 'profile#b' => [4]], + $store->inbox(['profile#a' => -1, 'profile#b' => -1], 2), + 'the limit applies to every subscription', + ); + self::assertSame([], $store->inbox(['profile#c' => -1], 10), 'an empty inbox is left out'); - $store->removeFromInbox('profile#a', 5); + $store->removeFromInbox(['profile#a' => 5, 'profile#b' => 3]); - self::assertSame([9], $store->inbox('profile#a', -1, 10)); - self::assertSame([4], $store->inbox('profile#b', -1, 10)); + self::assertSame(['profile#a' => [9], 'profile#b' => [4]], $store->inbox(['profile#a' => -1, 'profile#b' => -1], 10)); } public function testPendingPartitionsOnly(): void @@ -276,8 +279,7 @@ public function testPendingPartitionsOnly(): void $store->add(Subscription::partition($main, 'processed', 7)); $store->add(Subscription::partition($main, 'empty', 0)); - $store->addToInbox('profile#pending', [4]); - $store->addToInbox('profile#processed', [7]); + $store->addToInbox(['profile#pending' => [4], 'profile#processed' => [7]]); self::assertSame( ['profile', 'profile#pending'], @@ -297,11 +299,28 @@ public function testRemoveAlsoRemovesTheInbox(): void $partition = Subscription::partition(new Subscription('profile'), 'a', 0); $store->add($partition); - $store->addToInbox($partition->id(), [1, 2]); + $store->addToInbox([$partition->id() => [1, 2]]); $store->remove($partition); $store->add($partition); - self::assertSame([], $store->inbox($partition->id(), -1, 10)); + self::assertSame([], $store->inbox([$partition->id() => -1], 10)); + } + + public function testClaimAll(): void + { + $store = new InMemorySubscriptionStore(); + + $store->add(new Subscription('a', status: Status::Active)); + $store->add(new Subscription('b', status: Status::Paused)); + $store->add(new Subscription('c', status: Status::Active)); + + self::assertSame( + ['a', 'c'], + array_map( + static fn (Subscription $s) => $s->id(), + $store->claimAll(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), + ), + ); } } From 3c3350068cce43eb86cc7a1aa6b1b5962397fa56 Mon Sep 17 00:00:00 2001 From: David Badura Date: Wed, 14 Oct 2026 23:26:20 +0200 Subject: [PATCH 04/57] Insert and update subscriptions in batches The subscription store gets addAll() and updateAll(), and the manager uses them when a flush has more than one subscription, e.g. a group of partitions or a router creating new partitions while catching up. addAll() is a multi row insert. updateAll() sets every column with a CASE over the id, which is the only form of a batch update that PostgreSQL, MySQL, MariaDB and SQLite share. PostgreSQL can not infer the type of the parameters in a CASE, so they are cast there. Since MySQL and MariaDB only count changed rows, a short affected row count is checked against the existing ids before a SubscriptionNotFound is thrown. --- docs/UPGRADE-4.0.md | 3 +- .../Engine/SubscriptionManager.php | 21 +- .../Store/DoctrineSubscriptionStore.php | 244 +++++++++++++++--- .../Store/InMemorySubscriptionStore.php | 16 ++ src/Subscription/Store/SubscriptionStore.php | 18 ++ .../DoctrineSubscriptionStoreTest.php | 109 ++++++++ .../Subscription/DummySubscriptionStore.php | 16 ++ .../Store/InMemorySubscriptionStoreTest.php | 14 + 8 files changed, 407 insertions(+), 34 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index bafd1a2eb..bd59bc1fb 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -517,7 +517,8 @@ Adjust the table names if you configured your own, and the `ALTER COLUMN` syntax A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()`, implement the inbox methods `addToInbox()`, `inbox()` and `removeFromInbox()`, remove the inbox in `remove()`, and support the new -`partitions` and `pendingPartitionsOnly` criteria. The `ids` of `SubscriptionCriteria` now match the subscription +`partitions` and `pendingPartitionsOnly` criteria. It also has to implement `claimAll()`, `addAll()` and `updateAll()`, +which work on several subscriptions at once. A simple loop over `claim()`, `add()` and `update()` is enough. The `ids` of `SubscriptionCriteria` now match the subscription id or the subscriber id, so the id of a partitioned subscriber also selects its partitions. For subscribers without partitions nothing changes, because the subscriber id is the subscription id. diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index b2004a4d0..89f2a85e9 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -283,14 +283,18 @@ public function inbox(array $afterIndex, int $limit): array public function flush(): void { + $toAdd = []; + foreach ($this->forAdd as $subscription) { if ($this->forRemove->offsetExists($subscription)) { continue; } - $this->subscriptionStore->add($subscription); + $toAdd[] = $subscription; } + $toUpdate = []; + foreach ($this->forUpdate as $subscription) { if ($this->forAdd->offsetExists($subscription)) { continue; @@ -300,7 +304,20 @@ public function flush(): void continue; } - $this->subscriptionStore->update($subscription); + $toUpdate[] = $subscription; + } + + // a group of partitions or a catching up router changes many subscriptions at once + if (count($toAdd) === 1) { + $this->subscriptionStore->add($toAdd[0]); + } elseif ($toAdd !== []) { + $this->subscriptionStore->addAll($toAdd); + } + + if (count($toUpdate) === 1) { + $this->subscriptionStore->update($toUpdate[0]); + } elseif ($toUpdate !== []) { + $this->subscriptionStore->updateAll($toUpdate); } foreach ($this->forRemove as $subscription) { diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index b50873cc9..e5a8de409 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -13,6 +13,7 @@ use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\ParameterType; use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; use Doctrine\DBAL\Query\QueryBuilder; @@ -30,11 +31,13 @@ use function array_chunk; use function array_fill; +use function array_keys; use function array_map; use function array_merge; use function assert; use function count; use function implode; +use function in_array; use function json_decode; use function json_encode; use function serialize; @@ -64,6 +67,53 @@ final class DoctrineSubscriptionStore implements SubscriptionStore, DoctrineSche /** How many inbox rows or conditions go into one statement, far below the parameter limits of the databases. */ private const INBOX_CHUNK = 500; + /** How many subscriptions go into one insert or update, 100 rows stay below 3000 parameters. */ + private const ROW_CHUNK = 100; + + /** In the order of the insert. */ + private const COLUMN_TYPES = [ + 'id' => ParameterType::STRING, + 'group_name' => ParameterType::STRING, + 'run_mode' => ParameterType::STRING, + 'status' => ParameterType::STRING, + 'position' => ParameterType::INTEGER, + 'error_message' => ParameterType::STRING, + 'error_previous_status' => ParameterType::STRING, + 'error_context' => ParameterType::STRING, + 'retry_attempt' => ParameterType::INTEGER, + 'last_saved_at' => Types::DATETIME_IMMUTABLE, + 'cleanup_tasks' => ParameterType::STRING, + 'subscriber_id' => ParameterType::STRING, + 'partition_key' => ParameterType::STRING, + ]; + + /** The columns an update may change, the id, the subscriber id and the partition key never change. */ + private const UPDATE_COLUMNS = [ + 'group_name', + 'run_mode', + 'status', + 'position', + 'error_message', + 'error_previous_status', + 'error_context', + 'retry_attempt', + 'last_saved_at', + 'cleanup_tasks', + ]; + + private const POSTGRES_CASTS = [ + 'group_name' => 'VARCHAR', + 'run_mode' => 'VARCHAR', + 'status' => 'VARCHAR', + 'position' => 'INTEGER', + 'error_message' => 'TEXT', + 'error_previous_status' => 'VARCHAR', + 'error_context' => 'JSON', + 'retry_attempt' => 'INTEGER', + 'last_saved_at' => 'TIMESTAMP(0) WITH TIME ZONE', + 'cleanup_tasks' => 'TEXT', + ]; + private readonly string $inboxTableName; public function __construct( @@ -268,28 +318,12 @@ private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria) public function add(Subscription $subscription): void { - $subscriptionError = $subscription->subscriptionError(); - $subscription->updateLastSavedAt($this->clock->now()); try { $this->connection->insert( $this->tableName, - [ - 'id' => $subscription->id(), - 'group_name' => $subscription->group(), - 'run_mode' => $subscription->runMode()->value, - 'status' => $subscription->status()->value, - 'position' => $subscription->position(), - 'error_message' => $subscriptionError?->errorMessage, - 'error_previous_status' => $subscriptionError?->previousStatus?->value, - 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, - 'retry_attempt' => $subscription->retryAttempt(), - 'last_saved_at' => $subscription->lastSavedAt(), - 'cleanup_tasks' => $subscription->cleanupTasks() !== null ? serialize($subscription->cleanupTasks()) : null, - 'subscriber_id' => $subscription->subscriberId(), - 'partition_key' => $subscription->partitionKey(), - ], + $this->row($subscription), [ 'last_saved_at' => Types::DATETIME_IMMUTABLE, ], @@ -299,26 +333,58 @@ public function add(Subscription $subscription): void } } - public function update(Subscription $subscription): void + /** @param non-empty-list $subscriptions */ + public function addAll(array $subscriptions): void { - $subscriptionError = $subscription->subscriptionError(); + $now = $this->clock->now(); + + foreach (array_chunk($subscriptions, self::ROW_CHUNK) as $chunk) { + $values = []; + $parameters = []; + $types = []; + + foreach ($chunk as $subscription) { + $subscription->updateLastSavedAt($now); + + $row = $this->row($subscription); + $values[] = '(' . implode(', ', array_fill(0, count($row), '?')) . ')'; + + foreach ($row as $column => $value) { + $parameters[] = $value; + $types[] = self::COLUMN_TYPES[$column]; + } + } + + try { + $this->connection->executeStatement( + sprintf( + 'INSERT INTO %s (%s) VALUES %s', + $this->tableName, + implode(', ', array_keys(self::COLUMN_TYPES)), + implode(', ', $values), + ), + $parameters, + $types, + ); + } catch (UniqueConstraintViolationException $e) { + throw new SubscriptionAlreadyExists( + implode('", "', array_map(static fn (Subscription $subscription): string => $subscription->id(), $chunk)), + $e, + ); + } + } + } + public function update(Subscription $subscription): void + { $subscription->updateLastSavedAt($this->clock->now()); + $row = $this->row($subscription); + unset($row['id'], $row['subscriber_id'], $row['partition_key']); + $effectedRows = $this->connection->update( $this->tableName, - [ - 'group_name' => $subscription->group(), - 'run_mode' => $subscription->runMode()->value, - 'status' => $subscription->status()->value, - 'position' => $subscription->position(), - 'error_message' => $subscriptionError?->errorMessage, - 'error_previous_status' => $subscriptionError?->previousStatus?->value, - 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, - 'retry_attempt' => $subscription->retryAttempt(), - 'last_saved_at' => $subscription->lastSavedAt(), - 'cleanup_tasks' => $subscription->cleanupTasks() !== null ? serialize($subscription->cleanupTasks()) : null, - ], + $row, [ 'id' => $subscription->id(), ], @@ -347,6 +413,122 @@ public function update(Subscription $subscription): void } } + /** + * One UPDATE per chunk with a CASE per column, the only form of a batch update that all supported databases share. + * + * @param non-empty-list $subscriptions + */ + public function updateAll(array $subscriptions): void + { + $now = $this->clock->now(); + $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform; + + foreach (array_chunk($subscriptions, self::ROW_CHUNK) as $chunk) { + $rows = []; + + foreach ($chunk as $subscription) { + $subscription->updateLastSavedAt($now); + $rows[$subscription->id()] = $this->row($subscription); + } + + $assignments = []; + $parameters = []; + $types = []; + + foreach (self::UPDATE_COLUMNS as $column) { + $cases = []; + + foreach ($rows as $id => $row) { + // postgres can not infer the type of a parameter in a CASE and would assign text to every column + $cases[] = $isPostgres + ? sprintf('WHEN ? THEN CAST(? AS %s)', self::POSTGRES_CASTS[$column]) + : 'WHEN ? THEN ?'; + + $parameters[] = (string)$id; + $parameters[] = $row[$column]; + $types[] = ParameterType::STRING; + $types[] = self::COLUMN_TYPES[$column]; + } + + $assignments[] = sprintf('%s = CASE id %s END', $column, implode(' ', $cases)); + } + + $ids = array_keys($rows); + + foreach ($ids as $id) { + $parameters[] = (string)$id; + $types[] = ParameterType::STRING; + } + + $effectedRows = $this->connection->executeStatement( + sprintf( + 'UPDATE %s SET %s WHERE id IN (%s)', + $this->tableName, + implode(', ', $assignments), + implode(', ', array_fill(0, count($ids), '?')), + ), + $parameters, + $types, + ); + + if ($effectedRows === count($ids)) { + continue; + } + + // mysql and mariadb count the changed rows, not the matched ones, so check which subscriptions exist + $existing = $this->connection->fetchFirstColumn( + sprintf('SELECT id FROM %s WHERE id IN (%s)', $this->tableName, implode(', ', array_fill(0, count($ids), '?'))), + array_map(static fn (int|string $id): string => (string)$id, $ids), + ); + + foreach ($ids as $id) { + if (!in_array((string)$id, $existing, true)) { + throw new SubscriptionNotFound((string)$id); + } + } + } + } + + /** + * The values of all columns, last_saved_at is converted by its type. + * + * @return array{ + * id: string, + * group_name: string, + * run_mode: string, + * status: string, + * position: int|null, + * error_message: string|null, + * error_previous_status: string|null, + * error_context: string|null, + * retry_attempt: int, + * last_saved_at: DateTimeImmutable|null, + * cleanup_tasks: string|null, + * subscriber_id: string, + * partition_key: string|null, + * } + */ + private function row(Subscription $subscription): array + { + $subscriptionError = $subscription->subscriptionError(); + + return [ + 'id' => $subscription->id(), + 'group_name' => $subscription->group(), + 'run_mode' => $subscription->runMode()->value, + 'status' => $subscription->status()->value, + 'position' => $subscription->position(), + 'error_message' => $subscriptionError?->errorMessage, + 'error_previous_status' => $subscriptionError?->previousStatus?->value, + 'error_context' => $subscriptionError?->errorContext !== null ? json_encode($subscriptionError->errorContext, JSON_THROW_ON_ERROR) : null, + 'retry_attempt' => $subscription->retryAttempt(), + 'last_saved_at' => $subscription->lastSavedAt(), + 'cleanup_tasks' => $subscription->cleanupTasks() !== null ? serialize($subscription->cleanupTasks()) : null, + 'subscriber_id' => $subscription->subscriberId(), + 'partition_key' => $subscription->partitionKey(), + ]; + } + public function remove(Subscription $subscription): void { $this->connection->delete($this->tableName, ['id' => $subscription->id()]); diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index 5646a73f2..8dba00f11 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -155,6 +155,22 @@ public function add(Subscription $subscription): void $this->subscriptions[$subscription->id()] = $subscription; } + /** @param non-empty-list $subscriptions */ + public function addAll(array $subscriptions): void + { + foreach ($subscriptions as $subscription) { + $this->add($subscription); + } + } + + /** @param non-empty-list $subscriptions */ + public function updateAll(array $subscriptions): void + { + foreach ($subscriptions as $subscription) { + $this->update($subscription); + } + } + public function update(Subscription $subscription): void { if (!array_key_exists($subscription->id(), $this->subscriptions)) { diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index ad3a6ab6e..b7c9e6729 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -31,9 +31,27 @@ public function claimAll(array $ids, SubscriptionCriteria $criteria): array; /** @throws SubscriptionAlreadyExists */ public function add(Subscription $subscription): void; + /** + * Adds several subscriptions at once. + * + * @param non-empty-list $subscriptions + * + * @throws SubscriptionAlreadyExists + */ + public function addAll(array $subscriptions): void; + /** @throws SubscriptionNotFound */ public function update(Subscription $subscription): void; + /** + * Updates several subscriptions at once. + * + * @param non-empty-list $subscriptions + * + * @throws SubscriptionNotFound + */ + public function updateAll(array $subscriptions): void; + /** * Removes the subscription together with its inbox. * diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index cd21bfd5f..14a23e30a 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1213,4 +1213,113 @@ public function testClaimAll(): void ), ); } + + public function testAddAll(): void + { + $store = $this->createStore(); + + $main = new Subscription('foo', 'bar', RunMode::Once, Status::Active, 10, null, 3, null, [new DropTableTask('baz')]); + $main->error('error message'); + $partition = Subscription::partition(new Subscription('foo', status: Status::Booting), 'tenant-a', 5); + + $store->addAll([$main, $partition, new Subscription('other')]); + + self::assertEquals($main, $store->get('foo')); + self::assertEquals($partition, $store->get('foo#tenant-a')); + self::assertSame('foo', $store->get('foo#tenant-a')->subscriberId()); + self::assertSame(Status::New, $store->get('other')->status()); + } + + public function testAddAllDuplicate(): void + { + $store = $this->createStore(); + $store->add(new Subscription('foo')); + + $this->expectException(SubscriptionAlreadyExists::class); + + $store->addAll([new Subscription('bar'), new Subscription('foo')]); + } + + public function testUpdateAll(): void + { + $store = $this->createStore(); + + $store->addAll([ + new Subscription('a', status: Status::Active, position: 1), + new Subscription('b', status: Status::Active, position: 1), + new Subscription('c', status: Status::Active, position: 1), + ]); + + $a = $store->get('a'); + $a->changePosition(42); + $a->changeGroup('other'); + $a->changeRunMode(RunMode::Once); + $a->replaceCleanupTasks([new DropIndexTask('idx', 'table')]); + + $b = $store->get('b'); + $b->error(new RuntimeException('error message')); + + $store->updateAll([$a, $b]); + + $loadedA = $store->get('a'); + self::assertSame(42, $loadedA->position()); + self::assertSame('other', $loadedA->group()); + self::assertSame(RunMode::Once, $loadedA->runMode()); + self::assertEquals([new DropIndexTask('idx', 'table')], $loadedA->cleanupTasks()); + self::assertNull($loadedA->subscriptionError()); + + $loadedB = $store->get('b'); + self::assertSame(Status::Error, $loadedB->status()); + self::assertSame(1, $loadedB->position()); + self::assertSame('error message', $loadedB->subscriptionError()?->errorMessage); + self::assertSame(RuntimeException::class, $loadedB->subscriptionError()?->errorContext[0]['class'] ?? null); + + self::assertSame(1, $store->get('c')->position(), 'other subscriptions are not touched'); + } + + public function testUpdateAllWithoutChanges(): void + { + $store = $this->createStore(); + + $store->addAll([new Subscription('a'), new Subscription('b')]); + + // mysql and mariadb report no affected rows if nothing changed + $store->updateAll([$store->get('a'), $store->get('b')]); + $store->updateAll([$store->get('a'), $store->get('b')]); + + self::assertSame(Status::New, $store->get('a')->status()); + } + + public function testUpdateAllUnknownSubscription(): void + { + $store = $this->createStore(); + $store->add(new Subscription('a')); + + $this->expectException(SubscriptionNotFound::class); + + $store->updateAll([$store->get('a'), new Subscription('unknown')]); + } + + public function testUpdateAllInChunks(): void + { + $store = $this->createStore(); + + $subscriptions = []; + + for ($i = 0; $i < 250; $i++) { + $subscriptions[] = new Subscription('s' . $i, status: Status::Active, position: 0); + } + + $store->addAll($subscriptions); + + foreach ($subscriptions as $i => $subscription) { + $subscription->changePosition($i + 1); + } + + $store->updateAll($subscriptions); + + self::assertCount(250, $store->find()); + self::assertSame(1, $store->get('s0')->position()); + self::assertSame(250, $store->get('s249')->position()); + } } diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index adff0e511..f9c9a11a8 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -71,6 +71,22 @@ public function add(Subscription $subscription): void $this->addedSubscriptions[] = clone $subscription; } + /** @param non-empty-list $subscriptions */ + public function addAll(array $subscriptions): void + { + foreach ($subscriptions as $subscription) { + $this->add($subscription); + } + } + + /** @param non-empty-list $subscriptions */ + public function updateAll(array $subscriptions): void + { + foreach ($subscriptions as $subscription) { + $this->update($subscription); + } + } + public function update(Subscription $subscription): void { $this->parentStore->update($subscription); diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index 9f4d16afd..218bc0ca5 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -323,4 +323,18 @@ public function testClaimAll(): void ), ); } + + public function testAddAllAndUpdateAll(): void + { + $store = new InMemorySubscriptionStore(); + + $store->addAll([new Subscription('a'), new Subscription('b')]); + + $a = $store->get('a'); + $a->changePosition(5); + $store->updateAll([$a, $store->get('b')]); + + self::assertSame(5, $store->get('a')->position()); + self::assertCount(2, $store->find()); + } } From 24acd43018dff0784712996b0b0d54af2b77b171 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 06:58:03 +0200 Subject: [PATCH 05/57] Never let the batches of a claimed group overlap A batch often keeps a database transaction open from begin to flush. When several partitions or subscriptions of a group were processed message by message, their transactions on the shared connection were nested, and the rollback of a failing batch hit the innermost transaction of another batch. That batch lost its writes while its position moved on, and the failing one was committed. Subscribers with batching now run one after another within a group, the others are still processed together and the writes of the group are still flushed at once. --- docs/subscription.md | 1 + .../Engine/SubscriptionRunner.php | 47 +++++++ .../Subscription/GroupedClaimTest.php | 121 +++++++++++++++++- .../PartitionedSubscriptionTest.php | 45 +++++++ ...artitionedTransactionalBatchProjection.php | 93 ++++++++++++++ 5 files changed, 305 insertions(+), 2 deletions(-) create mode 100644 tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php diff --git a/docs/subscription.md b/docs/subscription.md index f73a5f5fd..559644f30 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1635,6 +1635,7 @@ $subscriptionEngine->execute(new Run(limit: 100, subscriptionsPerClaim: 10, part ``` An error still only stops the subscription it happened in, the others of the group are committed. Partitions and other subscriptions are never mixed in one group. +Subscribers with [batching](#batching) are processed one after another within a group, so their batches never overlap. :::warning A group holds the locks of all its subscriptions until it is committed. diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index e4e0b406b..997c23f9c 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -55,6 +55,14 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $surviving[] = $subscription; } + if (count($surviving) > 1) { + $separated = $this->processBatchesSeparately($surviving, $limit, $boot); + + if ($separated !== null) { + return $separated; + } + } + $members = new SubscriptionCollection($surviving); if (count($members) === 0) { @@ -174,6 +182,45 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals ); } + /** + * Batches of a group must not overlap. A batch often holds a resource like a database transaction from begin to + * flush, and overlapping transactions on one connection are a stack: rolling back one batch would roll back the + * innermost, so another batch would lose its writes while its position moves on. So every subscription with + * batching runs alone, one after the other, while the others of the group are still processed together. + * They all stay in the claimed transaction, so the writes of the group are still flushed together. + * + * @param list $subscriptions + */ + private function processBatchesSeparately(array $subscriptions, int|null $limit, bool $boot): ProcessedResult|null + { + $batched = []; + $others = []; + + foreach ($subscriptions as $subscription) { + if ($this->subscriberRepository->get($subscription->subscriberId())?->metadata()->batch !== null) { + $batched[] = $subscription; + } else { + $others[] = $subscription; + } + } + + if ($batched === []) { + return null; + } + + $results = []; + + if ($others !== []) { + $results[] = $this->process($others, $limit, $boot); + } + + foreach ($batched as $subscription) { + $results[] = $this->process([$subscription], $limit, $boot); + } + + return ProcessedResult::merge($results); + } + /** * @param positive-int $limit * diff --git a/tests/Integration/Subscription/GroupedClaimTest.php b/tests/Integration/Subscription/GroupedClaimTest.php index 838793f2b..ad2b71eac 100644 --- a/tests/Integration/Subscription/GroupedClaimTest.php +++ b/tests/Integration/Subscription/GroupedClaimTest.php @@ -6,7 +6,13 @@ use DateTimeImmutable; use Doctrine\DBAL\Connection; +use Doctrine\DBAL\Schema\Table; +use Patchlevel\EventSourcing\Attribute\BatchBegin; +use Patchlevel\EventSourcing\Attribute\BatchFlush; +use Patchlevel\EventSourcing\Attribute\BatchRollback; +use Patchlevel\EventSourcing\Attribute\BatchState; use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Metadata\AggregateRoot\AggregateRootRegistry; @@ -17,7 +23,7 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; -use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; +use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup as SetupCommand; use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; @@ -27,10 +33,13 @@ use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\BatchProfileProjection; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; use RuntimeException; +use stdClass; use function gc_collect_cycles; @@ -115,7 +124,7 @@ public function onProfileCreated(ProfileCreated $event): void $store, ))->get(Profile::class); - $engine->execute(new Setup(skipBooting: true)); + $engine->execute(new SetupCommand(skipBooting: true)); $repository->save(Profile::create(ProfileId::generate(), 'John')); $repository->save(Profile::create(ProfileId::generate(), 'Jane')); @@ -134,4 +143,112 @@ public function onProfileCreated(ProfileCreated $event): void self::assertSame(Status::Active, $subscriptionStore->get('second')->status()); self::assertSame(2, $subscriptionStore->get('second')->position()); } + + public function testTransactionalBatchesOfAGroupDoNotOverlap(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + $clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); + $subscriptionStore = new DoctrineSubscriptionStore($this->connection, $clock); + + (new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([$store, $subscriptionStore]), + ))->create(); + + // both keep a transaction open from batch begin to flush on the same connection + $poisoned = new BatchProfileProjection($this->connection); + $healthy = new #[Projector('healthy_batch')] + class ($this->connection) { + public function __construct( + private readonly Connection $connection, + ) { + } + + #[Setup] + public function create(): void + { + $table = new Table('healthy_batch'); + $table->addColumn('id', 'string')->setLength(36); + $table->addColumn('name', 'string')->setLength(255); + $table->setPrimaryKey(['id']); + + $this->connection->createSchemaManager()->createTable($table); + } + + #[BatchBegin] + public function begin(): stdClass + { + $this->connection->beginTransaction(); + + return new stdClass(); + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event, #[BatchState] + stdClass $state,): void + { + $this->connection->insert('healthy_batch', ['id' => $event->profileId->toString(), 'name' => $event->name]); + } + + #[Subscribe(NameChanged::class)] + public function onNameChanged(NameChanged $event, #[BatchState] + stdClass $state,): void + { + $this->connection->update('healthy_batch', ['name' => $event->name], ['id' => $event->profileId->toString()]); + } + + #[BatchFlush] + public function flush(stdClass $state): void + { + $this->connection->commit(); + } + + #[BatchRollback] + public function rollback(stdClass $state): void + { + $this->connection->rollBack(); + } + }; + + $subscriberRepository = new MetadataSubscriberAccessorRepository([$poisoned, $healthy]); + + $engine = new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + new RetryStrategyRepository([ + RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy($clock), + ]), + ); + + $repository = (new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ))->get(Profile::class); + + $engine->execute(new SetupCommand(skipBooting: true)); + + $id = ProfileId::generate(); + $repository->save(Profile::create($id, 'John')); + $profile = $repository->load($id); + $profile->changeName(BatchProfileProjection::POISON); + $repository->save($profile); + + // without separate batches, the rollback of the poisoned projection would hit the transaction of the healthy one + for ($i = 0; $i < 5; $i++) { + $engine->execute(new Run(subscriptionsPerClaim: 10)); + } + + self::assertSame([], $this->connection->fetchAllAssociative('SELECT * FROM projection_' . BatchProfileProjection::SUBSCRIBER_ID)); + self::assertSame(Status::Error, $subscriptionStore->get(BatchProfileProjection::SUBSCRIBER_ID)->status()); + self::assertSame( + [['id' => $id->toString(), 'name' => BatchProfileProjection::POISON]], + $this->connection->fetchAllAssociative('SELECT id, name FROM healthy_batch'), + ); + self::assertSame(Status::Active, $subscriptionStore->get('healthy_batch')->status()); + } } diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 18b5064cb..7350e8da4 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -35,7 +35,9 @@ use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedBatchProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedProfileProjection; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedTransactionalBatchProjection; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use function array_keys; @@ -428,4 +430,47 @@ public function testLimitAppliesToEveryPartitionOfAClaim(): void self::assertSame(['name-' . $i], $projection->processed[$id], 'a limit of 1 still lets every partition of the group process a message'); } } + + /** @return iterable */ + public static function partitionsPerClaim(): iterable + { + yield 'one partition per claim' => [1]; + yield 'partitions in groups' => [100]; + } + + /** + * Every batch is a transaction on the shared connection. Partition A fails after partition B started its batch: + * only A may be rolled back, B has to be committed. + * + * @param positive-int $partitionsPerClaim + */ + #[DataProvider('partitionsPerClaim')] + public function testTransactionalBatchesOfAGroupDoNotOverlap(int $partitionsPerClaim): void + { + $engine = $this->engine(new PartitionedTransactionalBatchProjection($this->connection)); + + $engine->execute(new Setup(skipBooting: true)); + + $a = ProfileId::generate(); + $b = ProfileId::generate(); + $this->repository->save(Profile::create($a, 'A')); + $this->repository->save(Profile::create($b, 'B')); + $this->changeName($a, PartitionedTransactionalBatchProjection::POISON); + $this->changeName($b, 'B2'); + + $this->runUntilFinished($engine, partitionsPerClaim: $partitionsPerClaim); + + self::assertSame( + [['id' => $b->toString(), 'name' => 'B2']], + $this->connection->fetchAllAssociative('SELECT id, name FROM ' . PartitionedTransactionalBatchProjection::SUBSCRIBER_ID), + ); + + $partitions = $this->partitions(PartitionedTransactionalBatchProjection::SUBSCRIBER_ID); + $partitionOfA = $partitions[Subscription::partitionId(PartitionedTransactionalBatchProjection::SUBSCRIBER_ID, 'profile-' . $a->toString())]; + $partitionOfB = $partitions[Subscription::partitionId(PartitionedTransactionalBatchProjection::SUBSCRIBER_ID, 'profile-' . $b->toString())]; + + self::assertSame(Status::Error, $partitionOfA->status()); + self::assertSame(0, $partitionOfA->position(), 'the failed batch did not move the position'); + self::assertSame(Status::Active, $partitionOfB->status()); + } } diff --git a/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php new file mode 100644 index 000000000..bcab45e4c --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php @@ -0,0 +1,93 @@ +addColumn('id', 'string')->setLength(36); + $table->addColumn('name', 'string')->setLength(255); + $table->setPrimaryKey(['id']); + + $this->connection->createSchemaManager()->createTable($table); + } + + #[Partition] + public function partition(Message $message): string + { + return $message->header(StreamNameHeader::class)->streamName; + } + + #[BatchBegin] + public function begin(): stdClass + { + $this->connection->beginTransaction(); + + return new stdClass(); + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event, #[BatchState] + stdClass $state,): void + { + $this->connection->insert(self::SUBSCRIBER_ID, ['id' => $event->profileId->toString(), 'name' => $event->name]); + } + + #[Subscribe(NameChanged::class)] + public function onNameChanged(NameChanged $event, #[BatchState] + stdClass $state,): void + { + if ($event->name === self::POISON) { + throw new RuntimeException('poisoned event'); + } + + $this->connection->update(self::SUBSCRIBER_ID, ['name' => $event->name], ['id' => $event->profileId->toString()]); + } + + #[BatchFlush] + public function flush(stdClass $state): void + { + $this->connection->commit(); + } + + #[BatchRollback] + public function rollback(stdClass $state): void + { + $this->connection->rollBack(); + } +} From 7520d0a33d946376b1cf05d2fc0bbe67bd35ae07 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 07:14:59 +0200 Subject: [PATCH 06/57] Only group subscriptions with close positions A group reads the stream from its lowest position and the message limit applies to the whole group. A subscription far behind, e.g. one that is rebuilt, used up the limit before the others reached their new events, so they waited until the rebuild had caught up. Subscriptions are now sorted by position and only grouped while they are at most the message limit apart (1000 without a limit). Within that gap there are never more messages than the limit, so every member of a group is reached in the same run. Without grouping the order of the snapshot is kept as before. --- docs/subscription.md | 2 + .../Engine/Handler/BootHandler.php | 2 + .../Engine/Handler/RunHandler.php | 2 + .../Engine/SubscriptionManager.php | 90 +++++++++++-- .../Subscription/GroupedClaimTest.php | 127 ++++++++++++++++++ .../Engine/SubscriptionManagerTest.php | 47 ++++++- 6 files changed, 256 insertions(+), 14 deletions(-) diff --git a/docs/subscription.md b/docs/subscription.md index 559644f30..41354febd 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1624,6 +1624,8 @@ To process a single subscriber in parallel, split it into [partitions](#partitio By default every subscription is claimed and processed in its own transaction. With `subscriptionsPerClaim`, several subscriptions are processed together: the stream is read once from the lowest position, and the group is committed in one transaction. +Only subscriptions whose positions are at most `limit` apart (1000 without a limit) form a group, +so a subscription that is rebuilt does not hold back the ones that are up to date. Partitions are claimed in groups of 100 by default, because they only read their inbox and are often many. ```php diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index bc011ec54..828007b71 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -54,6 +54,8 @@ public function __invoke(Command $command): ProcessedResult $subscriptions, ), ), + // the group reads the stream from its lowest position, a larger gap would use up the limit of the others + $command->limit ?? SubscriptionManager::DEFAULT_POSITION_WINDOW, ); return ProcessedResult::merge($results); diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index afd58bc00..8af719bb4 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -54,6 +54,8 @@ public function __invoke(Command $command): ProcessedResult $subscriptions, ), ), + // the group reads the stream from its lowest position, a larger gap would use up the limit of the others + $command->limit ?? SubscriptionManager::DEFAULT_POSITION_WINDOW, ); return ProcessedResult::merge($results); diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 89f2a85e9..d218131b2 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -18,10 +18,14 @@ use function implode; use function max; use function sprintf; +use function usort; /** @internal */ final class SubscriptionManager { + /** Subscriptions further apart than that are not processed together, if there is no message limit. */ + public const DEFAULT_POSITION_WINDOW = 1000; + /** @var SplObjectStorage */ private SplObjectStorage $forAdd; @@ -83,8 +87,12 @@ static function (array $subscriptions) use ($closure): mixed { * inbox. A subscription that is locked by another worker is left out of its group. * If the closure throws, every subscription of the group goes into the error status. * + * Subscriptions are only grouped if their positions are at most $positionWindow apart, the group reads the stream + * from its lowest position and a subscription far behind would hold back the others. + * * @param positive-int $partitionsPerClaim * @param positive-int $subscriptionsPerClaim + * @param positive-int $positionWindow * @param Closure(non-empty-list):T $closure * @param Closure(non-empty-list, Throwable):T $onError * @@ -98,9 +106,15 @@ public function forEachClaimedGroup( int $subscriptionsPerClaim, Closure $closure, Closure|null $onError = null, + int $positionWindow = self::DEFAULT_POSITION_WINDOW, ): array { $results = []; - $groups = $this->groups($this->subscriptionStore->find($criteria), $partitionsPerClaim, $subscriptionsPerClaim); + $groups = $this->groups( + $this->subscriptionStore->find($criteria), + $partitionsPerClaim, + $subscriptionsPerClaim, + $positionWindow, + ); foreach ($groups as $candidates) { $skipped = false; @@ -189,38 +203,88 @@ private function claim(array $candidates, SubscriptionCriteria $criteria): array } /** - * Keeps the order of the snapshot as far as possible, a group is closed as soon as it is full. + * Partitions are grouped in the order of the snapshot. Other subscriptions are grouped by position, so that only + * subscriptions close to each other read the stream together. Without grouping, the order of the snapshot is kept. * * @param list $subscriptions * @param positive-int $partitionsPerClaim * @param positive-int $subscriptionsPerClaim + * @param positive-int $positionWindow * * @return list> */ - private function groups(array $subscriptions, int $partitionsPerClaim, int $subscriptionsPerClaim): array - { + private function groups( + array $subscriptions, + int $partitionsPerClaim, + int $subscriptionsPerClaim, + int $positionWindow, + ): array { $groups = []; - $open = ['partitions' => [], 'subscriptions' => []]; + $partitions = []; + $others = []; foreach ($subscriptions as $subscription) { - $kind = $subscription->isPartition() ? 'partitions' : 'subscriptions'; - $size = $subscription->isPartition() ? $partitionsPerClaim : $subscriptionsPerClaim; + if (!$subscription->isPartition()) { + if ($subscriptionsPerClaim === 1) { + $groups[] = [$subscription]; + } else { + $others[] = $subscription; + } + + continue; + } - $open[$kind][] = $subscription; + $partitions[] = $subscription; - if (count($open[$kind]) < $size) { + if (count($partitions) < $partitionsPerClaim) { continue; } - $groups[] = $open[$kind]; - $open[$kind] = []; + $groups[] = $partitions; + $partitions = []; + } + + if ($partitions !== []) { + $groups[] = $partitions; } - foreach ($open as $group) { + return [...$this->groupByPosition($others, $subscriptionsPerClaim, $positionWindow), ...$groups]; + } + + /** + * @param list $subscriptions + * @param positive-int $size + * @param positive-int $window + * + * @return list> + */ + private function groupByPosition(array $subscriptions, int $size, int $window): array + { + usort( + $subscriptions, + static fn (Subscription $a, Subscription $b): int => ($a->position() ?? -1) <=> ($b->position() ?? -1), + ); + + $groups = []; + $group = []; + $lowest = null; + + foreach ($subscriptions as $subscription) { + $position = $subscription->position() ?? -1; + + if ($group !== [] && (count($group) >= $size || $position - (int)$lowest > $window)) { + $groups[] = $group; + $group = []; + } + if ($group === []) { - continue; + $lowest = $position; } + $group[] = $subscription; + } + + if ($group !== []) { $groups[] = $group; } diff --git a/tests/Integration/Subscription/GroupedClaimTest.php b/tests/Integration/Subscription/GroupedClaimTest.php index ad2b71eac..3ca11b233 100644 --- a/tests/Integration/Subscription/GroupedClaimTest.php +++ b/tests/Integration/Subscription/GroupedClaimTest.php @@ -251,4 +251,131 @@ public function rollback(stdClass $state): void ); self::assertSame(Status::Active, $subscriptionStore->get('healthy_batch')->status()); } + + public function testSubscriptionFarBehindDoesNotHoldBackTheGroup(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + $subscriptionStore = new DoctrineSubscriptionStore($this->connection); + + (new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([$store, $subscriptionStore]), + ))->create(); + + $current = new #[Projector('current')] + class { + public int $handled = 0; + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->handled++; + } + }; + + $lagging = new #[Projector('lagging')] + class { + public int $handled = 0; + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + $this->handled++; + } + }; + + $subscriberRepository = new MetadataSubscriberAccessorRepository([$current, $lagging]); + + $engine = new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + ); + + $repository = (new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ))->get(Profile::class); + + $engine->execute(new SetupCommand(skipBooting: true)); + + for ($i = 0; $i < 50; $i++) { + $repository->save(Profile::create(ProfileId::generate(), 'name-' . $i)); + } + + // current is up to date, lagging is still at the beginning like during a rebuild + $engine->execute(new Run(ids: ['current'])); + + $repository->save(Profile::create(ProfileId::generate(), 'new')); + + $engine->execute(new Run(limit: 10, subscriptionsPerClaim: 10)); + + self::assertSame(51, $subscriptionStore->get('current')->position(), 'the new event is not held back by the rebuild'); + self::assertSame(51, $current->handled); + self::assertSame(10, $subscriptionStore->get('lagging')->position()); + self::assertSame(10, $lagging->handled); + } + + public function testSubscriptionsWithClosePositionsReadTheStreamOnce(): void + { + $store = new StreamDoctrineDbalStore( + $this->connection, + DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), + ); + + $subscriptionStore = new DoctrineSubscriptionStore($this->connection); + + (new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([$store, $subscriptionStore]), + ))->create(); + + $first = new #[Projector('first')] + class { + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + } + }; + + $second = new #[Projector('second')] + class { + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + } + }; + + $subscriberRepository = new MetadataSubscriberAccessorRepository([$first, $second]); + + $engine = new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + ); + + $repository = (new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ))->get(Profile::class); + + $engine->execute(new SetupCommand(skipBooting: true)); + + for ($i = 0; $i < 5; $i++) { + $repository->save(Profile::create(ProfileId::generate(), 'name-' . $i)); + } + + $engine->execute(new Run(ids: ['first'], limit: 2)); + + $result = $engine->execute(new Run(limit: 10, subscriptionsPerClaim: 10)); + + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertSame(5, $result->processedMessages, 'positions 2 and 0 are close, so the stream is read once from 0'); + self::assertSame(5, $subscriptionStore->get('first')->position()); + self::assertSame(5, $subscriptionStore->get('second')->position()); + } } diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index fffd0f4e5..4c12f57bd 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -208,7 +208,7 @@ public function testForEachClaimedGroupKeepsPartitionsAndSubscriptionsApart(): v ), ); - self::assertSame([['a#1', 'a#2'], ['a', 'b'], ['a#3'], ['c']], $groups); + self::assertSame([['a', 'b'], ['c'], ['a#1', 'a#2'], ['a#3']], $groups); } public function testForEachClaimedGroupPutsTheWholeGroupIntoError(): void @@ -232,4 +232,49 @@ public function testForEachClaimedGroupPutsTheWholeGroupIntoError(): void self::assertSame(Status::Error, $store->get('a')->status()); self::assertSame(Status::Error, $store->get('b')->status()); } + + public function testForEachClaimedGroupOnlyGroupsSubscriptionsWithClosePositions(): void + { + $store = new InMemorySubscriptionStore([ + new Subscription('rebuild', position: 0), + new Subscription('far', position: 50_000), + new Subscription('current-1', position: 2_003), + new Subscription('almost-rebuild', position: 5), + new Subscription('current-2', position: 2_000), + new Subscription('new'), + ]); + + $manager = new SubscriptionManager($store); + + $groups = $manager->forEachClaimedGroup( + new SubscriptionCriteria(), + 1, + 10, + static fn (array $subscriptions): array => array_map( + static fn (Subscription $subscription): string => $subscription->id(), + $subscriptions, + ), + positionWindow: 1000, + ); + + self::assertSame( + [['new', 'rebuild', 'almost-rebuild'], ['current-2', 'current-1'], ['far']], + $groups, + ); + } + + public function testForEachClaimedKeepsTheOrderOfTheSnapshot(): void + { + $store = new InMemorySubscriptionStore([ + new Subscription('b', position: 100), + new Subscription('a', position: 0), + ]); + + $manager = new SubscriptionManager($store); + + self::assertSame( + ['b', 'a'], + $manager->forEachClaimed(new SubscriptionCriteria(), static fn (Subscription $subscription): string => $subscription->id()), + ); + } } From 20b1bf95d257d23474a37197b0feb1e6817816df Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 07:55:14 +0200 Subject: [PATCH 07/57] Clear the processed messages of a failed partition from its inbox A partition that failed left the run before its inbox was cleaned up, so the messages it had processed before the error stayed in the inbox. They were only removed with the next successful run, and never if the partition stayed failed. Now they are removed up to its position, which is the last processed message, or the position before a rolled back batch. --- .../Engine/SubscriptionRunner.php | 27 +++++++++++++ .../PartitionedSubscriptionTest.php | 40 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 997c23f9c..aa5959dba 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -81,6 +81,9 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $lastIndex = null; $limitReached = false; + /** @var list $failedPartitions */ + $failedPartitions = []; + if (count($working) > 0) { [$startIndex, $endIndex] = $this->range($working, $inboxes); $indices = $this->inboxIndices($working, $inboxes); @@ -131,6 +134,10 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $working->remove($member); $members->remove($member); + if ($member->isPartition()) { + $failedPartitions[] = $member; + } + if (count($working) === 0) { break 2; } @@ -169,6 +176,11 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals } } + // a failed partition keeps the messages from its error on, the ones before are processed and can go + foreach ($failedPartitions as $partition) { + $this->removeProcessedFromInbox($partition); + } + $inboxFull = $this->hasFullInbox($members, $inboxes, $inboxLimit); if (!$limitReached && !$inboxFull) { @@ -361,6 +373,21 @@ private function finishInbox(Subscription $partition, PartitionInbox $inbox): vo $this->subscriptionManager->removeFromInbox($partition->id(), $position); } + /** + * The position of a failed partition is the last message it processed successfully, + * after a rolled back batch the position before the batch. + */ + private function removeProcessedFromInbox(Subscription $partition): void + { + $position = $partition->position(); + + if ($position === null) { + return; + } + + $this->subscriptionManager->removeFromInbox($partition->id(), $position); + } + /** @param array $inboxes */ private function hasFullInbox(SubscriptionCollection $members, array $inboxes, int $limit): bool { diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 7350e8da4..ca11eac30 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -471,6 +471,46 @@ public function testTransactionalBatchesOfAGroupDoNotOverlap(int $partitionsPerC self::assertSame(Status::Error, $partitionOfA->status()); self::assertSame(0, $partitionOfA->position(), 'the failed batch did not move the position'); + self::assertSame( + [$partitionOfA->id() => [1, 3]], + $this->subscriptionStore->inbox([$partitionOfA->id() => -1], 10), + 'nothing of the rolled back batch leaves the inbox', + ); self::assertSame(Status::Active, $partitionOfB->status()); } + + public function testFailedPartitionKeepsOnlyTheUnprocessedMessagesInItsInbox(): void + { + $projection = new PartitionedProfileProjection(); + $projection->failingNames = ['broken']; + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $id = ProfileId::generate(); + $this->repository->save(Profile::create($id, 'n1')); + $this->changeName($id, 'n2'); + $this->changeName($id, 'broken'); + $this->changeName($id, 'n4'); + + $this->runUntilFinished($engine); + + $partition = $this->partitions()[Subscription::partitionId(self::MAIN, 'profile-' . $id->toString())]; + + self::assertSame(Status::Error, $partition->status()); + self::assertSame(2, $partition->position()); + self::assertSame(['n1', 'n2'], $projection->processed[$id->toString()]); + self::assertSame( + [$partition->id() => [3, 4]], + $this->subscriptionStore->inbox([$partition->id() => -1], 10), + 'the processed messages before the error leave the inbox', + ); + + $projection->failingNames = []; + $this->clock->sleep(10); + $this->runUntilFinished($engine); + + self::assertSame(['n1', 'n2', 'broken', 'n4'], $projection->processed[$id->toString()]); + self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10)); + } } From ccbc05f3dede65868f8aab6c56988fedf498f26f Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 08:51:31 +0200 Subject: [PATCH 08/57] Require PHP 8.4 Subscriptions loaded in bulk will use native lazy objects for their heavy columns, which need PHP 8.4. --- .github/workflows/unit.yml | 2 -- composer.json | 4 ++-- composer.lock | 6 +++--- docs/UPGRADE-4.0.md | 4 ++++ 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/.github/workflows/unit.yml b/.github/workflows/unit.yml index 269c23682..d015c6a99 100644 --- a/.github/workflows/unit.yml +++ b/.github/workflows/unit.yml @@ -21,8 +21,6 @@ jobs: - "lowest" - "highest" php-version: - - "8.2" - - "8.3" - "8.4" - "8.5" operating-system: diff --git a/composer.json b/composer.json index 0961bd096..11d2345fb 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ } ], "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "php": "~8.4.0 || ~8.5.0", "doctrine/dbal": "^4.4.0", "doctrine/migrations": "^3.3.2", "patchlevel/hydrator": "^2.0.2", @@ -48,7 +48,7 @@ "symfony/type-info": "^7.2.0 || ^8.0.0" }, "require-dev": { - "ext-pdo_sqlite": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", + "ext-pdo_sqlite": "~8.4.0 || ~8.5.0", "doctrine/orm": "^2.18.0 || ^3.0.0", "infection/infection": "^0.32.0", "league/commonmark": "^2.6.1", diff --git a/composer.lock b/composer.lock index d31f55436..8ba19c9bd 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "c703d9a37598197f2f8bceee5da066c0", + "content-hash": "d6be649e1b37cd5e48593ac3929ccf1c", "packages": [ { "name": "brick/math", @@ -8186,10 +8186,10 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "php": "~8.4.0 || ~8.5.0" }, "platform-dev": { - "ext-pdo_sqlite": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0" + "ext-pdo_sqlite": "~8.4.0 || ~8.5.0" }, "plugin-api-version": "2.9.0" } diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index bd59bc1fb..8ee499d3f 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -3,6 +3,10 @@ searchable: false --- # Upgrade 4.0 +## PHP + +The minimum PHP version is now 8.4. + ## Aggregates ### Aggregate Root From 055d49bab9091982523cf9a3bebc662e0b59d6a8 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 08:59:23 +0200 Subject: [PATCH 09/57] Load error context and cleanup tasks lazily for engine lookups Every run loaded all errored subscriptions with their full error context just to claim each of them and find out that the retry was not due yet. With a thousand failed partitions that took more than 128 MB and the worker died on every run. The store gets findLazy(), which loads the error context and the cleanup tasks with native lazy ghosts on first access. Saving such a subscription reads them, so nothing gets lost. find() stays eager, so the public subscriptions() and anything comparing or dumping subscriptions is not affected. The retry listener now checks on that snapshot whether a retry is due and only claims those subscriptions. --- docs/UPGRADE-4.0.md | 4 +- .../Engine/Handler/RefreshHandler.php | 7 +- .../Engine/Listener/DetachListener.php | 2 +- .../Engine/Listener/DiscoverSubscriber.php | 2 +- .../Listener/RemovePartitionsListener.php | 2 +- .../Engine/Listener/RetrySubscriber.php | 26 +-- src/Subscription/Engine/PartitionRouter.php | 4 +- .../Engine/SubscriptionManager.php | 33 +++- .../Engine/SubscriptionRunner.php | 2 +- .../Store/DoctrineSubscriptionStore.php | 152 +++++++++++++++++- .../Store/InMemorySubscriptionStore.php | 6 + src/Subscription/Store/SubscriptionStore.php | 8 + .../DoctrineSubscriptionStoreTest.php | 60 +++++++ .../Subscription/DummySubscriptionStore.php | 15 ++ .../Listener/DiscoverSubscriberTest.php | 2 +- .../Engine/Listener/RetrySubscriberTest.php | 25 +++ .../Engine/SubscriptionManagerTest.php | 6 +- 17 files changed, 322 insertions(+), 34 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 8ee499d3f..cbbaab359 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -522,7 +522,9 @@ Adjust the table names if you configured your own, and the `ALTER COLUMN` syntax A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()`, implement the inbox methods `addToInbox()`, `inbox()` and `removeFromInbox()`, remove the inbox in `remove()`, and support the new `partitions` and `pendingPartitionsOnly` criteria. It also has to implement `claimAll()`, `addAll()` and `updateAll()`, -which work on several subscriptions at once. A simple loop over `claim()`, `add()` and `update()` is enough. The `ids` of `SubscriptionCriteria` now match the subscription +which work on several subscriptions at once. A simple loop over `claim()`, `add()` and `update()` is enough. +`findLazy()` may return subscriptions whose error context and cleanup tasks are loaded on first access, +the engine uses it for lookups that only need the other fields. Returning the result of `find()` is enough. The `ids` of `SubscriptionCriteria` now match the subscription id or the subscriber id, so the id of a partitioned subscriber also selects its partitions. For subscribers without partitions nothing changes, because the subscriber id is the subscription id. diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php index 154a85980..9bf679306 100644 --- a/src/Subscription/Engine/Handler/RefreshHandler.php +++ b/src/Subscription/Engine/Handler/RefreshHandler.php @@ -32,7 +32,7 @@ public function __construct( public function __invoke(Command $command): Result { - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( + $subscriptions = $this->subscriptionManager->findLazy(new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, )); @@ -76,10 +76,11 @@ public function __invoke(Command $command): Result ); } - // cleanup tasks belong to the subscriber as a whole and are only stored on its main subscription + // cleanup tasks belong to the subscriber as a whole and are only stored on its main subscription, + // so the lazily loaded tasks of a partition are not touched at all $cleanupTasks = $subscription->isPartition() ? null : $this->cleanupTasks($subscriber); - if ($subscription->cleanupTasks() !== $cleanupTasks) { + if (!$subscription->isPartition() && $subscription->cleanupTasks() !== $cleanupTasks) { $changed = true; $subscription->replaceCleanupTasks($cleanupTasks); diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php index 9c2b93c5a..4a8dbb814 100644 --- a/src/Subscription/Engine/Listener/DetachListener.php +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -51,7 +51,7 @@ function (Subscription $subscription): void { $subscription->detached(); $this->subscriptionManager->update($subscription); - $partitions = $this->subscriptionManager->find(new SubscriptionCriteria( + $partitions = $this->subscriptionManager->findLazy(new SubscriptionCriteria( ids: [$subscription->id()], status: [Status::Active, Status::Paused, Status::Finished], partitions: true, diff --git a/src/Subscription/Engine/Listener/DiscoverSubscriber.php b/src/Subscription/Engine/Listener/DiscoverSubscriber.php index 28fa46940..eeb275bb6 100644 --- a/src/Subscription/Engine/Listener/DiscoverSubscriber.php +++ b/src/Subscription/Engine/Listener/DiscoverSubscriber.php @@ -51,7 +51,7 @@ public static function getSubscribedEvents(): array private function discover(): void { - $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria(partitions: false)); + $subscriptions = $this->subscriptionManager->findLazy(new SubscriptionCriteria(partitions: false)); $latestIndex = null; diff --git a/src/Subscription/Engine/Listener/RemovePartitionsListener.php b/src/Subscription/Engine/Listener/RemovePartitionsListener.php index b6921e889..8a39b81e5 100644 --- a/src/Subscription/Engine/Listener/RemovePartitionsListener.php +++ b/src/Subscription/Engine/Listener/RemovePartitionsListener.php @@ -36,7 +36,7 @@ public function onSubscriptionRemoved(OnSubscriptionRemoved $event): void return; } - $partitions = $this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)); + $partitions = $this->subscriptionManager->findLazy(new SubscriptionCriteria(ids: [$main->id()], partitions: true)); if ($partitions === []) { return; diff --git a/src/Subscription/Engine/Listener/RetrySubscriber.php b/src/Subscription/Engine/Listener/RetrySubscriber.php index eda2132a4..4a2805167 100644 --- a/src/Subscription/Engine/Listener/RetrySubscriber.php +++ b/src/Subscription/Engine/Listener/RetrySubscriber.php @@ -57,17 +57,8 @@ public function onCommand(OnCommand $event): void status: [Status::Error], ), function (Subscription $subscription) use ($status): void { - $error = $subscription->subscriptionError(); - - if ($error === null) { - return; - } - - if ($error->previousStatus !== $status) { - return; - } - - if (!$this->retryStrategy($subscription)->shouldRetry($subscription)) { + // checked again, another worker may have retried it since the snapshot + if (!$this->isDue($subscription, $status)) { return; } @@ -83,9 +74,22 @@ function (Subscription $subscription) use ($status): void { ), ); }, + // only subscriptions whose retry is due are claimed, with many errored partitions most of them wait + filter: fn (Subscription $subscription): bool => $this->isDue($subscription, $status), ); } + private function isDue(Subscription $subscription, Status $status): bool + { + $error = $subscription->subscriptionError(); + + if ($error === null || $error->previousStatus !== $status) { + return false; + } + + return $this->retryStrategy($subscription)->shouldRetry($subscription); + } + public function onHandleMessageError(OnHandleMessageError $event): void { $retryStrategy = $this->retryStrategy($event->subscription); diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index bf68a1a77..fff3e43de 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -120,7 +120,7 @@ private function exists(Subscription $main, string $partitionId): bool $this->lookups[$main->id()] = ($this->lookups[$main->id()] ?? 0) + 1; if ($this->lookups[$main->id()] > self::SINGLE_LOOKUPS) { - foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { + foreach ($this->subscriptionManager->findLazy(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { $this->knownPartitions[$partition->id()] = true; } @@ -129,7 +129,7 @@ private function exists(Subscription $main, string $partitionId): bool return isset($this->knownPartitions[$partitionId]); } - if ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { + if ($this->subscriptionManager->findLazy(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { return false; } diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index d218131b2..54cea2b70 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -13,7 +13,9 @@ use SplObjectStorage; use Throwable; +use function array_filter; use function array_map; +use function array_values; use function count; use function implode; use function max; @@ -53,13 +55,18 @@ public function __construct( /** * @param Closure(Subscription):T $closure * @param Closure(Subscription, Throwable):T $onError + * @param Closure(Subscription):bool $filter decides on the unclaimed snapshot whether to claim at all * * @return list * * @template T */ - public function forEachClaimed(SubscriptionCriteria $criteria, Closure $closure, Closure|null $onError = null): array - { + public function forEachClaimed( + SubscriptionCriteria $criteria, + Closure $closure, + Closure|null $onError = null, + Closure|null $filter = null, + ): array { $groupOnError = null; if ($onError !== null) { @@ -78,6 +85,7 @@ static function (array $subscriptions) use ($closure): mixed { return $closure($subscriptions[0]); }, $groupOnError, + filter: $filter, ); } @@ -93,6 +101,8 @@ static function (array $subscriptions) use ($closure): mixed { * @param positive-int $partitionsPerClaim * @param positive-int $subscriptionsPerClaim * @param positive-int $positionWindow + * @param Closure(Subscription):bool $filter decides on the unclaimed snapshot whether to + * claim at all, the closure checks again * @param Closure(non-empty-list):T $closure * @param Closure(non-empty-list, Throwable):T $onError * @@ -107,10 +117,17 @@ public function forEachClaimedGroup( Closure $closure, Closure|null $onError = null, int $positionWindow = self::DEFAULT_POSITION_WINDOW, + Closure|null $filter = null, ): array { $results = []; + $snapshot = $this->subscriptionStore->findLazy($criteria); + + if ($filter !== null) { + $snapshot = array_values(array_filter($snapshot, $filter)); + } + $groups = $this->groups( - $this->subscriptionStore->find($criteria), + $snapshot, $partitionsPerClaim, $subscriptionsPerClaim, $positionWindow, @@ -297,6 +314,16 @@ public function find(SubscriptionCriteria $criteria): array return $this->subscriptionStore->find($criteria); } + /** + * For lookups that only need the cheap fields, the error context and the cleanup tasks may be loaded on access. + * + * @return list + */ + public function findLazy(SubscriptionCriteria $criteria): array + { + return $this->subscriptionStore->findLazy($criteria); + } + public function add(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index aa5959dba..86d00c0d5 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -464,7 +464,7 @@ private function isRoutingFinished(Subscription $subscription): bool private function main(Subscription $partition): Subscription|null { - return $this->subscriptionManager->find( + return $this->subscriptionManager->findLazy( new SubscriptionCriteria(ids: [$partition->subscriberId()], partitions: false), )[0] ?? null; } diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index e5a8de409..e1cb7ff29 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -28,6 +28,7 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Psr\Clock\ClockInterface; +use ReflectionClass; use function array_chunk; use function array_fill; @@ -61,6 +62,20 @@ * subscriber_id: string, * partition_key: string|null, * } + * @phpstan-type LightData = array{ + * id: string, + * group_name: string, + * run_mode: string, + * position: int|string|null, + * status: string, + * error_message: string|null, + * error_previous_status: string|null, + * retry_attempt: int|string, + * last_saved_at: string, + * subscriber_id: string, + * partition_key: string|null, + * } + * @phpstan-import-type Context from SubscriptionError */ final class DoctrineSubscriptionStore implements SubscriptionStore, DoctrineSchemaConfigurator { @@ -70,6 +85,21 @@ final class DoctrineSubscriptionStore implements SubscriptionStore, DoctrineSche /** How many subscriptions go into one insert or update, 100 rows stay below 3000 parameters. */ private const ROW_CHUNK = 100; + /** findLazy() leaves out error_context and cleanup_tasks, see createLazySubscription(). */ + private const LAZY_LOADED_FIND_COLUMNS = [ + 'id', + 'group_name', + 'run_mode', + 'position', + 'status', + 'error_message', + 'error_previous_status', + 'retry_attempt', + 'last_saved_at', + 'subscriber_id', + 'partition_key', + ]; + /** In the order of the insert. */ private const COLUMN_TYPES = [ 'id' => ParameterType::STRING, @@ -149,13 +179,29 @@ public function get(string $subscriptionId): Subscription * @return list */ public function find(SubscriptionCriteria|null $criteria = null): array + { + return $this->findBy($criteria, false); + } + + /** + * The error context and the cleanup tasks are loaded with one query on first access, see createLazySubscription(). + * + * @return list + */ + public function findLazy(SubscriptionCriteria $criteria): array + { + return $this->findBy($criteria, true); + } + + /** @return list */ + private function findBy(SubscriptionCriteria|null $criteria, bool $lazy): array { if ($criteria === null || !$criteria->pendingPartitionsOnly) { - return $this->fetch($criteria); + return $this->fetch($criteria, $lazy); } - $mains = $criteria->partitions !== true ? $this->fetch($criteria, false) : []; - $partitions = $criteria->partitions !== false ? $this->fetch($criteria, true) : []; + $mains = $criteria->partitions !== true ? $this->fetch($criteria, $lazy, false) : []; + $partitions = $criteria->partitions !== false ? $this->fetch($criteria, $lazy, true) : []; return [...$mains, ...$partitions]; } @@ -166,10 +212,19 @@ public function find(SubscriptionCriteria|null $criteria = null): array * * @return list */ - private function fetch(SubscriptionCriteria|null $criteria, bool|null $pendingPartitions = null): array + private function fetch(SubscriptionCriteria|null $criteria, bool $lazy, bool|null $pendingPartitions = null): array { + if ($lazy) { + // the error context and the cleanup tasks can be large, they are loaded on first access + $columns = $pendingPartitions === true + ? array_map(fn (string $column): string => $this->tableName . '.' . $column, self::LAZY_LOADED_FIND_COLUMNS) + : self::LAZY_LOADED_FIND_COLUMNS; + } else { + $columns = [$pendingPartitions === true ? $this->tableName . '.*' : '*']; + } + $qb = $this->connection->createQueryBuilder() - ->select($pendingPartitions === true ? $this->tableName . '.*' : '*') + ->select(...$columns) ->from($this->tableName) ->orderBy($pendingPartitions === true ? $this->tableName . '.id' : 'id'); @@ -201,7 +256,7 @@ private function fetch(SubscriptionCriteria|null $criteria, bool|null $pendingPa $result = $qb->fetchAllAssociative(); return array_map( - fn (array $data) => $this->createSubscription($data), + fn (array $data) => $lazy ? $this->createLazySubscription($data) : $this->createSubscription($data), $result, ); } @@ -745,6 +800,91 @@ private function createSubscription(array $row): Subscription ); } + /** + * Creates a lazy ghost: everything but the error context and the cleanup tasks is set right away, those two are loaded + * with one query when one of them is accessed. Saving the subscription reads them as well, so nothing gets lost. + * + * @param LightData $row + */ + private function createLazySubscription(array $row): Subscription + { + $details = $this->detailsLoader($row['id']); + + $error = null; + + if ($row['error_message'] !== null) { + $errorReflector = new ReflectionClass(SubscriptionError::class); + $error = $errorReflector->newLazyGhost(static function (SubscriptionError $ghost) use ($errorReflector, $details): void { + $errorReflector->getProperty('errorContext')->setValue($ghost, $details()['error_context']); + }); + + $errorReflector->getProperty('errorMessage')->setRawValueWithoutLazyInitialization($error, $row['error_message']); + $errorReflector->getProperty('previousStatus')->setRawValueWithoutLazyInitialization( + $error, + $row['error_previous_status'] !== null ? Status::from($row['error_previous_status']) : Status::New, + ); + } + + $reflector = new ReflectionClass(Subscription::class); + $subscription = $reflector->newLazyGhost(static function (Subscription $ghost) use ($reflector, $details): void { + $reflector->getProperty('cleanupTasks')->setValue($ghost, $details()['cleanup_tasks']); + }); + + $values = [ + 'id' => $row['id'], + 'group' => $row['group_name'], + 'runMode' => RunMode::from($row['run_mode']), + 'status' => Status::from($row['status']), + 'position' => $row['position'] !== null ? (int)$row['position'] : null, + 'error' => $error, + 'retryAttempt' => (int)$row['retry_attempt'], + 'lastSavedAt' => self::normalizeDateTime($row['last_saved_at'], $this->connection->getDatabasePlatform()), + 'partitionKey' => $row['partition_key'], + ]; + + foreach ($values as $property => $value) { + $reflector->getProperty($property)->setRawValueWithoutLazyInitialization($subscription, $value); + } + + return $subscription; + } + + /** @return Closure(): array{error_context: list|null, cleanup_tasks: list|null} */ + private function detailsLoader(string $id): Closure + { + /** @var array{error_context: list|null, cleanup_tasks: list|null}|null $details */ + $details = null; + + return function () use ($id, &$details): array { + if ($details !== null) { + /** @var array{error_context: list|null, cleanup_tasks: list|null} $loaded */ + $loaded = $details; + + return $loaded; + } + + /** @var array{error_context: string|null, cleanup_tasks: string|null}|false $row */ + $row = $this->connection->fetchAssociative( + sprintf('SELECT error_context, cleanup_tasks FROM %s WHERE id = ?', $this->tableName), + [$id], + ); + + if ($row === false) { + throw new SubscriptionNotFound($id); + } + + /** @var list|null $context */ + $context = $row['error_context'] !== null + ? json_decode($row['error_context'], true, 512, JSON_THROW_ON_ERROR) + : null; + + /** @var list|null $cleanupTasks */ + $cleanupTasks = $row['cleanup_tasks'] !== null ? unserialize($row['cleanup_tasks']) : null; + + return $details = ['error_context' => $context, 'cleanup_tasks' => $cleanupTasks]; + }; + } + private static function normalizeDateTime(mixed $value, AbstractPlatform $platform): DateTimeImmutable { $normalizedValue = Type::getType(Types::DATETIMETZ_IMMUTABLE)->convertToPHPValue($value, $platform); diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index 8dba00f11..a51cf4f71 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -61,6 +61,12 @@ public function find(SubscriptionCriteria|null $criteria = null): array ); } + /** @return list */ + public function findLazy(SubscriptionCriteria $criteria): array + { + return $this->find($criteria); + } + public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null { if (!array_key_exists($id, $this->subscriptions)) { diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index b7c9e6729..cbeb6dea5 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -15,6 +15,14 @@ public function get(string $subscriptionId): Subscription; /** @return list */ public function find(SubscriptionCriteria|null $criteria = null): array; + /** + * Like find(), but a store may load the error context and the cleanup tasks only when they are accessed. + * The engine uses it for snapshots it only needs to decide what to claim. + * + * @return list + */ + public function findLazy(SubscriptionCriteria $criteria): array; + /** Claims one subscription via a row lock (SKIP LOCKED); null if held by another worker or no match. */ public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null; diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 14a23e30a..7e7471217 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -21,10 +21,12 @@ use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; use Patchlevel\EventSourcing\Subscription\Subscription; +use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Patchlevel\EventSourcing\Tests\DbalManager; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use ReflectionClass; use RuntimeException; use function array_map; @@ -1322,4 +1324,62 @@ public function testUpdateAllInChunks(): void self::assertSame(1, $store->get('s0')->position()); self::assertSame(250, $store->get('s249')->position()); } + + public function testFindLazyLoadsErrorContextAndCleanupTasksOnAccess(): void + { + $store = $this->createStore(); + + $subscription = new Subscription('foo', status: Status::Active, position: 3, cleanupTasks: [new DropTableTask('baz')]); + $subscription->error(new RuntimeException('error message')); + $store->add($subscription); + + [$lazy] = $store->findLazy(new SubscriptionCriteria(ids: ['foo'])); + + $reflector = new ReflectionClass(Subscription::class); + $errorReflector = new ReflectionClass(SubscriptionError::class); + $error = $lazy->subscriptionError(); + + self::assertNotNull($error); + self::assertSame('error message', $error->errorMessage); + self::assertSame(Status::Active, $error->previousStatus); + self::assertSame(3, $lazy->position()); + self::assertTrue($reflector->isUninitializedLazyObject($lazy), 'cheap fields do not load anything'); + self::assertTrue($errorReflector->isUninitializedLazyObject($error)); + + self::assertSame(RuntimeException::class, $error->errorContext[0]['class'] ?? null); + self::assertEquals([new DropTableTask('baz')], $lazy->cleanupTasks()); + self::assertEquals($store->get('foo'), $lazy); + } + + public function testUpdateOfALazySubscriptionKeepsErrorContextAndCleanupTasks(): void + { + $store = $this->createStore(); + + $subscription = new Subscription('foo', status: Status::Active, position: 3, cleanupTasks: [new DropTableTask('baz')]); + $subscription->error(new RuntimeException('error message')); + $store->add($subscription); + + [$lazy] = $store->findLazy(new SubscriptionCriteria(ids: ['foo'])); + $lazy->changePosition(10); + $store->update($lazy); + + $loaded = $store->get('foo'); + + self::assertSame(10, $loaded->position()); + self::assertEquals([new DropTableTask('baz')], $loaded->cleanupTasks()); + self::assertSame(RuntimeException::class, $loaded->subscriptionError()?->errorContext[0]['class'] ?? null); + } + + public function testLazySubscriptionThatWasRemoved(): void + { + $store = $this->createStore(); + $store->add(new Subscription('foo')); + + [$lazy] = $store->findLazy(new SubscriptionCriteria(ids: ['foo'])); + $store->remove($store->get('foo')); + + $this->expectException(SubscriptionNotFound::class); + + self::assertNull($lazy->cleanupTasks()); + } } diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index f9c9a11a8..2d059bdfa 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -29,6 +29,9 @@ final class DummySubscriptionStore implements SubscriptionStore /** @var list */ public array $removedSubscriptions = []; + /** @var list */ + public array $claimedIds = []; + /** @param list $subscriptions */ public function __construct( array $subscriptions = [], @@ -48,8 +51,16 @@ public function find(SubscriptionCriteria|null $criteria = null): array return $this->parentStore->find($criteria); } + /** @return list */ + public function findLazy(SubscriptionCriteria $criteria): array + { + return $this->find($criteria); + } + public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null { + $this->claimedIds[] = $id; + return $this->parentStore->claim($id, $criteria); } @@ -129,6 +140,10 @@ public function removeFromInbox(array $toIndex): void */ public function claimAll(array $ids, SubscriptionCriteria $criteria): array { + foreach ($ids as $id) { + $this->claimedIds[] = $id; + } + return $this->parentStore->claimAll($ids, $criteria); } diff --git a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php index 9aeefa5cd..aab0fe9f0 100644 --- a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php @@ -209,7 +209,7 @@ class { $subscriptionStore ->expects($this->once()) - ->method('find') + ->method('findLazy') ->with(new SubscriptionCriteria()) ->willReturn([new Subscription('id1')]); diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php index edb3dceb2..4749cde63 100644 --- a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -147,4 +147,29 @@ class { $store->assertNoChanges(); } + + public function testOnlyDueSubscriptionsAreClaimed(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + }; + + $due = new Subscription('test', status: Status::Error, position: 0, error: new SubscriptionError('ERROR', Status::Active)); + $waiting = Subscription::partition(new Subscription('test'), 'a', 0); + $waiting->error('ERROR'); + + $store = new DummySubscriptionStore([$due, $waiting]); + + $retryStrategy = $this->createMock(RetryStrategy::class); + $retryStrategy->method('shouldRetry')->willReturnCallback( + static fn (Subscription $subscription): bool => $subscription->id() === 'test', + ); + + $listener = $this->createListener($store, [$subscriber], $retryStrategy); + $listener->onCommand(new OnCommand(new Run())); + + self::assertSame(['test'], $store->claimedIds, 'a subscription whose retry is not due is not claimed'); + self::assertSame(Status::Active, $store->get('test')->status()); + self::assertSame(Status::Error, $store->get('test#a')->status()); + } } diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index 4c12f57bd..19c480c75 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -121,7 +121,7 @@ public function testForEachClaimedProcessesClaimedSubscription(): void $criteria = new SubscriptionCriteria(); $store = $this->createMock(SubscriptionStore::class); - $store->expects($this->once())->method('find')->with($criteria)->willReturn([$subscription]); + $store->expects($this->once())->method('findLazy')->with($criteria)->willReturn([$subscription]); $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn($subscription); $store->expects($this->once())->method('update')->with($subscription); $store @@ -146,7 +146,7 @@ public function testForEachClaimedSkipsWhenClaimReturnsNull(): void $criteria = new SubscriptionCriteria(); $store = $this->createMock(SubscriptionStore::class); - $store->expects($this->once())->method('find')->with($criteria)->willReturn([$subscription]); + $store->expects($this->once())->method('findLazy')->with($criteria)->willReturn([$subscription]); $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn(null); $store->expects($this->never())->method('update'); $store @@ -166,7 +166,7 @@ public function testForEachClaimedIsolatesTransientErrors(): void $criteria = new SubscriptionCriteria(); $store = $this->createMock(SubscriptionStore::class); - $store->expects($this->once())->method('find')->with($criteria)->willReturn([$subscription]); + $store->expects($this->once())->method('findLazy')->with($criteria)->willReturn([$subscription]); $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn($subscription); $store ->expects($this->once()) From 21b43b5e4afeb736cb3cdb593c0864c695c2f0e9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 09:16:40 +0200 Subject: [PATCH 10/57] Keep the subscription store interface small The store had grown batch and lazy variants of most methods. Now there is one method per operation: claim() takes a list of ids, add(), update() and remove() take several subscriptions, and find() may load the error context and the cleanup tasks lazily. The Doctrine store only creates lazy ghosts when one of them is set, so plain subscriptions stay plain objects. The inbox moved into the optional PartitionInboxStore interface with just addToInbox() and inbox(). The store keeps the inbox of a partition to the messages after its position when the partition is updated or removed, so the engine no longer clears it separately. A store without it throws PartitionsNotSupported once a partitioned subscriber is processed. --- docs/UPGRADE-4.0.md | 39 ++-- .../Engine/Handler/RefreshHandler.php | 2 +- .../Engine/Listener/DetachListener.php | 2 +- .../Engine/Listener/DiscoverSubscriber.php | 2 +- .../Listener/RemovePartitionsListener.php | 2 +- src/Subscription/Engine/PartitionRouter.php | 4 +- .../Engine/PartitionsNotSupported.php | 23 +++ .../Engine/SubscriptionManager.php | 78 +++---- .../Engine/SubscriptionRunner.php | 42 +--- .../Store/DoctrineSubscriptionStore.php | 192 ++++++++++-------- .../Store/InMemorySubscriptionStore.php | 107 ++++------ .../Store/PartitionInboxStore.php | 29 +++ src/Subscription/Store/SubscriptionStore.php | 72 +------ .../DoctrineSubscriptionStoreTest.php | 67 ++++-- .../Subscription/SubscriptionTest.php | 37 +++- .../Subscription/DummySubscriptionStore.php | 74 +++---- .../Listener/DiscoverSubscriberTest.php | 2 +- .../Engine/SubscriptionManagerTest.php | 14 +- .../Store/DoctrineSubscriptionStoreTest.php | 52 ++--- .../Store/InMemorySubscriptionStoreTest.php | 33 ++- 20 files changed, 430 insertions(+), 443 deletions(-) create mode 100644 src/Subscription/Engine/PartitionsNotSupported.php create mode 100644 src/Subscription/Store/PartitionInboxStore.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index cbbaab359..f0b2dc421 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -454,8 +454,8 @@ This is mostly transparent, but a few contracts changed. #### SubscriptionStore `claim()` and `inLock()` are now mandatory parts of the `SubscriptionStore` interface, and the -separate `LockableSubscriptionStore` interface has been removed. Every custom store has to implement -both: +separate `LockableSubscriptionStore` interface has been removed. `add()`, `update()` and `remove()` take +several subscriptions at once. Every custom store has to implement: ```php use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; @@ -463,13 +463,23 @@ use Patchlevel\EventSourcing\Subscription\Subscription; interface SubscriptionStore { - // ... get/find/add/update/remove ... + // ... get/find ... /** - * Claim exactly one subscription via a row lock (SKIP LOCKED). Return null if the row is held - * by another worker or no longer matches the criteria. + * Claim the subscriptions via row locks (SKIP LOCKED). Leave out the ones that are held + * by another worker or no longer match the criteria. + * + * @param non-empty-list $ids + * + * @return list */ - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null; + public function claim(array $ids, SubscriptionCriteria $criteria): array; + + public function add(Subscription ...$subscriptions): void; + + public function update(Subscription ...$subscriptions): void; + + public function remove(Subscription ...$subscriptions): void; /** * @param Closure():T $closure @@ -484,6 +494,10 @@ interface SubscriptionStore `find()` no longer locks the matched rows: it is now a plain, unlocked snapshot read. The locking happens per subscription inside `claim()`. +The `DoctrineSubscriptionStore` loads the error context and the cleanup tasks of the subscriptions from `find()` +lazily. Subscriptions that have one of them are native lazy objects, which `var_dump()` and `assertEquals()` show +without these fields until they are accessed. + #### Message limit is now per subscription For `Run` and `Boot`, the `limit` used to cap the total number of messages across the shared stream. @@ -519,12 +533,13 @@ CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) NOT NULL, message ``` Adjust the table names if you configured your own, and the `ALTER COLUMN` syntax for MySQL (`MODIFY`). -A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()`, implement the inbox methods -`addToInbox()`, `inbox()` and `removeFromInbox()`, remove the inbox in `remove()`, and support the new -`partitions` and `pendingPartitionsOnly` criteria. It also has to implement `claimAll()`, `addAll()` and `updateAll()`, -which work on several subscriptions at once. A simple loop over `claim()`, `add()` and `update()` is enough. -`findLazy()` may return subscriptions whose error context and cleanup tasks are loaded on first access, -the engine uses it for lookups that only need the other fields. Returning the result of `find()` is enough. The `ids` of `SubscriptionCriteria` now match the subscription +A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()` and support the new +`partitions` and `pendingPartitionsOnly` criteria. To support partitions, it also has to implement the +`PartitionInboxStore` interface with `addToInbox()` and `inbox()`. The inbox of a partition only holds messages after its +position: updating a partition clears its inbox up to its position, removing it removes its inbox. +A store without partitions support throws `PartitionsNotSupported` as soon as a partitioned subscriber is processed. + +The `ids` of `SubscriptionCriteria` now match the subscription id or the subscriber id, so the id of a partitioned subscriber also selects its partitions. For subscribers without partitions nothing changes, because the subscriber id is the subscription id. diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php index 9bf679306..65760392b 100644 --- a/src/Subscription/Engine/Handler/RefreshHandler.php +++ b/src/Subscription/Engine/Handler/RefreshHandler.php @@ -32,7 +32,7 @@ public function __construct( public function __invoke(Command $command): Result { - $subscriptions = $this->subscriptionManager->findLazy(new SubscriptionCriteria( + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, )); diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php index 4a8dbb814..9c2b93c5a 100644 --- a/src/Subscription/Engine/Listener/DetachListener.php +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -51,7 +51,7 @@ function (Subscription $subscription): void { $subscription->detached(); $this->subscriptionManager->update($subscription); - $partitions = $this->subscriptionManager->findLazy(new SubscriptionCriteria( + $partitions = $this->subscriptionManager->find(new SubscriptionCriteria( ids: [$subscription->id()], status: [Status::Active, Status::Paused, Status::Finished], partitions: true, diff --git a/src/Subscription/Engine/Listener/DiscoverSubscriber.php b/src/Subscription/Engine/Listener/DiscoverSubscriber.php index eeb275bb6..28fa46940 100644 --- a/src/Subscription/Engine/Listener/DiscoverSubscriber.php +++ b/src/Subscription/Engine/Listener/DiscoverSubscriber.php @@ -51,7 +51,7 @@ public static function getSubscribedEvents(): array private function discover(): void { - $subscriptions = $this->subscriptionManager->findLazy(new SubscriptionCriteria(partitions: false)); + $subscriptions = $this->subscriptionManager->find(new SubscriptionCriteria(partitions: false)); $latestIndex = null; diff --git a/src/Subscription/Engine/Listener/RemovePartitionsListener.php b/src/Subscription/Engine/Listener/RemovePartitionsListener.php index 8a39b81e5..b6921e889 100644 --- a/src/Subscription/Engine/Listener/RemovePartitionsListener.php +++ b/src/Subscription/Engine/Listener/RemovePartitionsListener.php @@ -36,7 +36,7 @@ public function onSubscriptionRemoved(OnSubscriptionRemoved $event): void return; } - $partitions = $this->subscriptionManager->findLazy(new SubscriptionCriteria(ids: [$main->id()], partitions: true)); + $partitions = $this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)); if ($partitions === []) { return; diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index fff3e43de..bf68a1a77 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -120,7 +120,7 @@ private function exists(Subscription $main, string $partitionId): bool $this->lookups[$main->id()] = ($this->lookups[$main->id()] ?? 0) + 1; if ($this->lookups[$main->id()] > self::SINGLE_LOOKUPS) { - foreach ($this->subscriptionManager->findLazy(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { + foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { $this->knownPartitions[$partition->id()] = true; } @@ -129,7 +129,7 @@ private function exists(Subscription $main, string $partitionId): bool return isset($this->knownPartitions[$partitionId]); } - if ($this->subscriptionManager->findLazy(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { + if ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { return false; } diff --git a/src/Subscription/Engine/PartitionsNotSupported.php b/src/Subscription/Engine/PartitionsNotSupported.php new file mode 100644 index 000000000..d757ab8e7 --- /dev/null +++ b/src/Subscription/Engine/PartitionsNotSupported.php @@ -0,0 +1,23 @@ +> */ private array $forInbox = []; - /** @var array */ - private array $forInboxRemove = []; - public function __construct( private readonly SubscriptionStore $subscriptionStore, private readonly LoggerInterface|null $logger = null, @@ -120,7 +117,7 @@ public function forEachClaimedGroup( Closure|null $filter = null, ): array { $results = []; - $snapshot = $this->subscriptionStore->findLazy($criteria); + $snapshot = $this->subscriptionStore->find($criteria); if ($filter !== null) { $snapshot = array_values(array_filter($snapshot, $filter)); @@ -207,13 +204,7 @@ function () use ($candidates, $criteria, $closure, $onError, &$skipped): mixed { */ private function claim(array $candidates, SubscriptionCriteria $criteria): array { - if (count($candidates) === 1) { - $subscription = $this->subscriptionStore->claim($candidates[0]->id(), $criteria); - - return $subscription === null ? [] : [$subscription]; - } - - return $this->subscriptionStore->claimAll( + return $this->subscriptionStore->claim( array_map(static fn (Subscription $subscription): string => $subscription->id(), $candidates), $criteria, ); @@ -314,16 +305,6 @@ public function find(SubscriptionCriteria $criteria): array return $this->subscriptionStore->find($criteria); } - /** - * For lookups that only need the cheap fields, the error context and the cleanup tasks may be loaded on access. - * - * @return list - */ - public function findLazy(SubscriptionCriteria $criteria): array - { - return $this->subscriptionStore->findLazy($criteria); - } - public function add(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { @@ -348,17 +329,12 @@ public function remove(Subscription ...$subscriptions): void /** Routes a message into the inbox of a partition. */ public function addToInbox(string $subscriptionId, int $index): void { + $this->inboxStore(); $this->forInbox[$subscriptionId][] = $index; } - /** Removes the processed messages up to and including the given index from the inbox of a partition. */ - public function removeFromInbox(string $subscriptionId, int $toIndex): void - { - $this->forInboxRemove[$subscriptionId] = max($toIndex, $this->forInboxRemove[$subscriptionId] ?? $toIndex); - } - /** - * @param array $afterIndex subscription id => index after which the inbox is read + * @param array $afterIndex subscription id => position of the partition * @param positive-int $limit * * @return array> @@ -369,7 +345,16 @@ public function inbox(array $afterIndex, int $limit): array return []; } - return $this->subscriptionStore->inbox($afterIndex, $limit); + return $this->inboxStore()->inbox($afterIndex, $limit); + } + + private function inboxStore(): PartitionInboxStore + { + if (!$this->subscriptionStore instanceof PartitionInboxStore) { + throw new PartitionsNotSupported($this->subscriptionStore::class); + } + + return $this->subscriptionStore; } public function flush(): void @@ -398,33 +383,31 @@ public function flush(): void $toUpdate[] = $subscription; } - // a group of partitions or a catching up router changes many subscriptions at once - if (count($toAdd) === 1) { - $this->subscriptionStore->add($toAdd[0]); - } elseif ($toAdd !== []) { - $this->subscriptionStore->addAll($toAdd); - } - - if (count($toUpdate) === 1) { - $this->subscriptionStore->update($toUpdate[0]); - } elseif ($toUpdate !== []) { - $this->subscriptionStore->updateAll($toUpdate); - } + $toRemove = []; foreach ($this->forRemove as $subscription) { if ($this->forAdd->offsetExists($subscription)) { continue; } - $this->subscriptionStore->remove($subscription); + $toRemove[] = $subscription; } - if ($this->forInbox !== []) { - $this->subscriptionStore->addToInbox($this->forInbox); + // a group of partitions or a catching up router changes many subscriptions at once + if ($toAdd !== []) { + $this->subscriptionStore->add(...$toAdd); + } + + if ($toUpdate !== []) { + $this->subscriptionStore->update(...$toUpdate); } - if ($this->forInboxRemove !== []) { - $this->subscriptionStore->removeFromInbox($this->forInboxRemove); + if ($toRemove !== []) { + $this->subscriptionStore->remove(...$toRemove); + } + + if ($this->forInbox !== []) { + $this->inboxStore()->addToInbox($this->forInbox); } $this->clearPending(); @@ -436,6 +419,5 @@ private function clearPending(): void $this->forUpdate = new SplObjectStorage(); $this->forRemove = new SplObjectStorage(); $this->forInbox = []; - $this->forInboxRemove = []; } } diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 86d00c0d5..10c1f1da1 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -81,9 +81,6 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $lastIndex = null; $limitReached = false; - /** @var list $failedPartitions */ - $failedPartitions = []; - if (count($working) > 0) { [$startIndex, $endIndex] = $this->range($working, $inboxes); $indices = $this->inboxIndices($working, $inboxes); @@ -134,10 +131,6 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $working->remove($member); $members->remove($member); - if ($member->isPartition()) { - $failedPartitions[] = $member; - } - if (count($working) === 0) { break 2; } @@ -176,11 +169,6 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals } } - // a failed partition keeps the messages from its error on, the ones before are processed and can go - foreach ($failedPartitions as $partition) { - $this->removeProcessedFromInbox($partition); - } - $inboxFull = $this->hasFullInbox($members, $inboxes, $inboxLimit); if (!$limitReached && !$inboxFull) { @@ -353,39 +341,17 @@ private function inboxIndices(SubscriptionCollection $working, array $inboxes): /** * A partition never moves past its inbox, otherwise it would skip messages the main subscription routes later. - * Everything up to the new position has been processed and is removed from the inbox. + * The store clears the inbox up to the position when the partition is updated, a failed one included. */ private function finishInbox(Subscription $partition, PartitionInbox $inbox): void { $position = $partition->position(); - if ($position === null) { - return; - } - - $end = $inbox->last(); - - if ($position > $end) { - $partition->changePosition($end); - $position = $end; - } - - $this->subscriptionManager->removeFromInbox($partition->id(), $position); - } - - /** - * The position of a failed partition is the last message it processed successfully, - * after a rolled back batch the position before the batch. - */ - private function removeProcessedFromInbox(Subscription $partition): void - { - $position = $partition->position(); - - if ($position === null) { + if ($position === null || $position <= $inbox->last()) { return; } - $this->subscriptionManager->removeFromInbox($partition->id(), $position); + $partition->changePosition($inbox->last()); } /** @param array $inboxes */ @@ -464,7 +430,7 @@ private function isRoutingFinished(Subscription $subscription): bool private function main(Subscription $partition): Subscription|null { - return $this->subscriptionManager->findLazy( + return $this->subscriptionManager->find( new SubscriptionCriteria(ids: [$partition->subscriberId()], partitions: false), )[0] ?? null; } diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index e1cb7ff29..072730bef 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -35,6 +35,7 @@ use function array_keys; use function array_map; use function array_merge; +use function array_values; use function assert; use function count; use function implode; @@ -74,10 +75,12 @@ * last_saved_at: string, * subscriber_id: string, * partition_key: string|null, + * has_error_context: int|string, + * has_cleanup_tasks: int|string, * } * @phpstan-import-type Context from SubscriptionError */ -final class DoctrineSubscriptionStore implements SubscriptionStore, DoctrineSchemaConfigurator +final class DoctrineSubscriptionStore implements PartitionInboxStore, DoctrineSchemaConfigurator { /** How many inbox rows or conditions go into one statement, far below the parameter limits of the databases. */ private const INBOX_CHUNK = 500; @@ -85,8 +88,8 @@ final class DoctrineSubscriptionStore implements SubscriptionStore, DoctrineSche /** How many subscriptions go into one insert or update, 100 rows stay below 3000 parameters. */ private const ROW_CHUNK = 100; - /** findLazy() leaves out error_context and cleanup_tasks, see createLazySubscription(). */ - private const LAZY_LOADED_FIND_COLUMNS = [ + /** find() leaves out error_context and cleanup_tasks, see createLazySubscription(). */ + private const LAZY_LOADED_COLUMNS = [ 'id', 'group_name', 'run_mode', @@ -174,34 +177,19 @@ public function get(string $subscriptionId): Subscription } /** + * The error context and the cleanup tasks are loaded with one query on first access, see createLazySubscription(). * With pendingPartitionsOnly, the main subscriptions come first, then the partitions with messages in their inbox. * * @return list */ public function find(SubscriptionCriteria|null $criteria = null): array - { - return $this->findBy($criteria, false); - } - - /** - * The error context and the cleanup tasks are loaded with one query on first access, see createLazySubscription(). - * - * @return list - */ - public function findLazy(SubscriptionCriteria $criteria): array - { - return $this->findBy($criteria, true); - } - - /** @return list */ - private function findBy(SubscriptionCriteria|null $criteria, bool $lazy): array { if ($criteria === null || !$criteria->pendingPartitionsOnly) { - return $this->fetch($criteria, $lazy); + return $this->fetch($criteria); } - $mains = $criteria->partitions !== true ? $this->fetch($criteria, $lazy, false) : []; - $partitions = $criteria->partitions !== false ? $this->fetch($criteria, $lazy, true) : []; + $mains = $criteria->partitions !== true ? $this->fetch($criteria, false) : []; + $partitions = $criteria->partitions !== false ? $this->fetch($criteria, true) : []; return [...$mains, ...$partitions]; } @@ -212,16 +200,13 @@ private function findBy(SubscriptionCriteria|null $criteria, bool $lazy): array * * @return list */ - private function fetch(SubscriptionCriteria|null $criteria, bool $lazy, bool|null $pendingPartitions = null): array + private function fetch(SubscriptionCriteria|null $criteria, bool|null $pendingPartitions = null): array { - if ($lazy) { - // the error context and the cleanup tasks can be large, they are loaded on first access - $columns = $pendingPartitions === true - ? array_map(fn (string $column): string => $this->tableName . '.' . $column, self::LAZY_LOADED_FIND_COLUMNS) - : self::LAZY_LOADED_FIND_COLUMNS; - } else { - $columns = [$pendingPartitions === true ? $this->tableName . '.*' : '*']; - } + // the error context and the cleanup tasks can be large, only whether they exist is loaded right away + $prefix = $pendingPartitions === true ? $this->tableName . '.' : ''; + $columns = array_map(static fn (string $column): string => $prefix . $column, self::LAZY_LOADED_COLUMNS); + $columns[] = sprintf('CASE WHEN %serror_context IS NULL THEN 0 ELSE 1 END AS has_error_context', $prefix); + $columns[] = sprintf('CASE WHEN %scleanup_tasks IS NULL THEN 0 ELSE 1 END AS has_cleanup_tasks', $prefix); $qb = $this->connection->createQueryBuilder() ->select(...$columns) @@ -252,46 +237,21 @@ private function fetch(SubscriptionCriteria|null $criteria, bool $lazy, bool|nul ); } - /** @var list $result */ + /** @var list $result */ $result = $qb->fetchAllAssociative(); return array_map( - fn (array $data) => $lazy ? $this->createLazySubscription($data) : $this->createSubscription($data), + fn (array $data) => $this->createLazySubscription($data), $result, ); } - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null - { - $qb = $this->connection->createQueryBuilder() - ->select('*') - ->from($this->tableName) - ->where('id = :id') - ->setParameter('id', $id); - - $this->applyCriteria($qb, $criteria); - $this->applyPending($qb, $criteria); - - if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { - $qb->forUpdate(ConflictResolutionMode::SKIP_LOCKED); - } - - /** @var Data|false $result */ - $result = $qb->fetchAssociative(); - - if ($result === false) { - return null; - } - - return $this->createSubscription($result); - } - /** * @param non-empty-list $ids * * @return list */ - public function claimAll(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria): array { $qb = $this->connection->createQueryBuilder() ->select('*') @@ -371,7 +331,63 @@ private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria) $qb->andWhere($criteria->partitions ? 'partition_key IS NOT NULL' : 'partition_key IS NULL'); } - public function add(Subscription $subscription): void + public function add(Subscription ...$subscriptions): void + { + $subscriptions = array_values($subscriptions); + + if ($subscriptions === []) { + return; + } + + if (count($subscriptions) === 1) { + $this->insert($subscriptions[0]); + + return; + } + + $this->insertAll($subscriptions); + } + + /** The inbox of an updated partition is cleared up to its position. */ + public function update(Subscription ...$subscriptions): void + { + $subscriptions = array_values($subscriptions); + + if ($subscriptions === []) { + return; + } + + if (count($subscriptions) === 1) { + $this->updateOne($subscriptions[0]); + } else { + $this->updateAll($subscriptions); + } + + $processed = []; + + foreach ($subscriptions as $subscription) { + $position = $subscription->position(); + + if (!$subscription->isPartition() || $position === null) { + continue; + } + + $processed[$subscription->id()] = $position; + } + + $this->removeFromInbox($processed); + } + + /** The inbox of a removed partition is removed as well. */ + public function remove(Subscription ...$subscriptions): void + { + foreach ($subscriptions as $subscription) { + $this->connection->delete($this->tableName, ['id' => $subscription->id()]); + $this->connection->delete($this->inboxTableName, ['subscription_id' => $subscription->id()]); + } + } + + private function insert(Subscription $subscription): void { $subscription->updateLastSavedAt($this->clock->now()); @@ -389,7 +405,7 @@ public function add(Subscription $subscription): void } /** @param non-empty-list $subscriptions */ - public function addAll(array $subscriptions): void + private function insertAll(array $subscriptions): void { $now = $this->clock->now(); @@ -430,7 +446,7 @@ public function addAll(array $subscriptions): void } } - public function update(Subscription $subscription): void + private function updateOne(Subscription $subscription): void { $subscription->updateLastSavedAt($this->clock->now()); @@ -473,7 +489,7 @@ public function update(Subscription $subscription): void * * @param non-empty-list $subscriptions */ - public function updateAll(array $subscriptions): void + private function updateAll(array $subscriptions): void { $now = $this->clock->now(); $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform; @@ -584,12 +600,6 @@ private function row(Subscription $subscription): array ]; } - public function remove(Subscription $subscription): void - { - $this->connection->delete($this->tableName, ['id' => $subscription->id()]); - $this->connection->delete($this->inboxTableName, ['subscription_id' => $subscription->id()]); - } - /** @param array> $indices */ public function addToInbox(array $indices): void { @@ -666,7 +676,7 @@ public function inbox(array $afterIndex, int $limit): array } /** @param array $toIndex */ - public function removeFromInbox(array $toIndex): void + private function removeFromInbox(array $toIndex): void { foreach (array_chunk($toIndex, self::INBOX_CHUNK, true) as $chunk) { $conditions = []; @@ -801,8 +811,9 @@ private function createSubscription(array $row): Subscription } /** - * Creates a lazy ghost: everything but the error context and the cleanup tasks is set right away, those two are loaded - * with one query when one of them is accessed. Saving the subscription reads them as well, so nothing gets lost. + * Everything but the error context and the cleanup tasks is set right away. If there is an error context or there + * are cleanup tasks, they are loaded with one query when they are accessed (native lazy ghosts). Saving the + * subscription reads them, so nothing gets lost. Without them the subscription is a plain object. * * @param LightData $row */ @@ -813,23 +824,21 @@ private function createLazySubscription(array $row): Subscription $error = null; if ($row['error_message'] !== null) { - $errorReflector = new ReflectionClass(SubscriptionError::class); - $error = $errorReflector->newLazyGhost(static function (SubscriptionError $ghost) use ($errorReflector, $details): void { - $errorReflector->getProperty('errorContext')->setValue($ghost, $details()['error_context']); - }); - - $errorReflector->getProperty('errorMessage')->setRawValueWithoutLazyInitialization($error, $row['error_message']); - $errorReflector->getProperty('previousStatus')->setRawValueWithoutLazyInitialization( - $error, - $row['error_previous_status'] !== null ? Status::from($row['error_previous_status']) : Status::New, - ); + $previousStatus = $row['error_previous_status'] !== null ? Status::from($row['error_previous_status']) : Status::New; + + if ((int)$row['has_error_context'] === 1) { + $errorReflector = new ReflectionClass(SubscriptionError::class); + $error = $errorReflector->newLazyGhost(static function (SubscriptionError $ghost) use ($errorReflector, $details): void { + $errorReflector->getProperty('errorContext')->setValue($ghost, $details()['error_context']); + }); + + $errorReflector->getProperty('errorMessage')->setRawValueWithoutLazyInitialization($error, $row['error_message']); + $errorReflector->getProperty('previousStatus')->setRawValueWithoutLazyInitialization($error, $previousStatus); + } else { + $error = new SubscriptionError($row['error_message'], $previousStatus); + } } - $reflector = new ReflectionClass(Subscription::class); - $subscription = $reflector->newLazyGhost(static function (Subscription $ghost) use ($reflector, $details): void { - $reflector->getProperty('cleanupTasks')->setValue($ghost, $details()['cleanup_tasks']); - }); - $values = [ 'id' => $row['id'], 'group' => $row['group_name'], @@ -842,6 +851,15 @@ private function createLazySubscription(array $row): Subscription 'partitionKey' => $row['partition_key'], ]; + if ((int)$row['has_cleanup_tasks'] === 0) { + return new Subscription(...$values); + } + + $reflector = new ReflectionClass(Subscription::class); + $subscription = $reflector->newLazyGhost(static function (Subscription $ghost) use ($reflector, $details): void { + $reflector->getProperty('cleanupTasks')->setValue($ghost, $details()['cleanup_tasks']); + }); + foreach ($values as $property => $value) { $reflector->getProperty($property)->setRawValueWithoutLazyInitialization($subscription, $value); } diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index a51cf4f71..ed7a6e0c7 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -17,7 +17,7 @@ use function in_array; use function sort; -final class InMemorySubscriptionStore implements SubscriptionStore +final class InMemorySubscriptionStore implements PartitionInboxStore { /** @var array */ private array $subscriptions = []; @@ -61,40 +61,23 @@ public function find(SubscriptionCriteria|null $criteria = null): array ); } - /** @return list */ - public function findLazy(SubscriptionCriteria $criteria): array - { - return $this->find($criteria); - } - - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null - { - if (!array_key_exists($id, $this->subscriptions)) { - return null; - } - - $subscription = $this->subscriptions[$id]; - - if (!$this->matches($subscription, $criteria)) { - return null; - } - - return $subscription; - } - /** * @param non-empty-list $ids * * @return list */ - public function claimAll(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria): array { $claimed = []; foreach ($ids as $id) { - $subscription = $this->claim($id, $criteria); + if (!array_key_exists($id, $this->subscriptions)) { + continue; + } + + $subscription = $this->subscriptions[$id]; - if ($subscription === null) { + if (!$this->matches($subscription, $criteria)) { continue; } @@ -150,47 +133,53 @@ private function matches(Subscription $subscription, SubscriptionCriteria $crite return true; } - public function add(Subscription $subscription): void + public function add(Subscription ...$subscriptions): void { - if (array_key_exists($subscription->id(), $this->subscriptions)) { - throw new SubscriptionAlreadyExists($subscription->id()); - } - - $subscription->updateLastSavedAt($this->clock->now()); + foreach ($subscriptions as $subscription) { + if (array_key_exists($subscription->id(), $this->subscriptions)) { + throw new SubscriptionAlreadyExists($subscription->id()); + } - $this->subscriptions[$subscription->id()] = $subscription; - } + $subscription->updateLastSavedAt($this->clock->now()); - /** @param non-empty-list $subscriptions */ - public function addAll(array $subscriptions): void - { - foreach ($subscriptions as $subscription) { - $this->add($subscription); + $this->subscriptions[$subscription->id()] = $subscription; } } - /** @param non-empty-list $subscriptions */ - public function updateAll(array $subscriptions): void + /** The inbox of an updated partition is cleared up to its position. */ + public function update(Subscription ...$subscriptions): void { foreach ($subscriptions as $subscription) { - $this->update($subscription); - } - } + if (!array_key_exists($subscription->id(), $this->subscriptions)) { + throw new SubscriptionNotFound($subscription->id()); + } - public function update(Subscription $subscription): void - { - if (!array_key_exists($subscription->id(), $this->subscriptions)) { - throw new SubscriptionNotFound($subscription->id()); - } + $subscription->updateLastSavedAt($this->clock->now()); + + $this->subscriptions[$subscription->id()] = $subscription; - $subscription->updateLastSavedAt($this->clock->now()); + $position = $subscription->position(); + + if (!$subscription->isPartition() || $position === null) { + continue; + } + + foreach (array_keys($this->inbox[$subscription->id()] ?? []) as $messageIndex) { + if ($messageIndex > $position) { + continue; + } - $this->subscriptions[$subscription->id()] = $subscription; + unset($this->inbox[$subscription->id()][$messageIndex]); + } + } } - public function remove(Subscription $subscription): void + /** The inbox of a removed partition is removed as well. */ + public function remove(Subscription ...$subscriptions): void { - unset($this->subscriptions[$subscription->id()], $this->inbox[$subscription->id()]); + foreach ($subscriptions as $subscription) { + unset($this->subscriptions[$subscription->id()], $this->inbox[$subscription->id()]); + } } /** @param array> $indices */ @@ -231,20 +220,6 @@ public function inbox(array $afterIndex, int $limit): array return $inbox; } - /** @param array $toIndex */ - public function removeFromInbox(array $toIndex): void - { - foreach ($toIndex as $subscriptionId => $index) { - foreach (array_keys($this->inbox[$subscriptionId] ?? []) as $messageIndex) { - if ($messageIndex > $index) { - continue; - } - - unset($this->inbox[$subscriptionId][$messageIndex]); - } - } - } - public function clear(): void { $this->subscriptions = []; diff --git a/src/Subscription/Store/PartitionInboxStore.php b/src/Subscription/Store/PartitionInboxStore.php new file mode 100644 index 000000000..cd995ee31 --- /dev/null +++ b/src/Subscription/Store/PartitionInboxStore.php @@ -0,0 +1,29 @@ +> $indices subscription id => message indices */ + public function addToInbox(array $indices): void; + + /** + * Returns at most $limit indices per partition after its position, in ascending order. + * Partitions with an empty inbox are left out. + * + * @param array $afterIndex subscription id => position of the partition + * @param positive-int $limit + * + * @return array> + */ + public function inbox(array $afterIndex, int $limit): array; +} diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index cbeb6dea5..f355289e7 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -12,86 +12,30 @@ interface SubscriptionStore /** @throws SubscriptionNotFound */ public function get(string $subscriptionId): Subscription; - /** @return list */ - public function find(SubscriptionCriteria|null $criteria = null): array; - /** - * Like find(), but a store may load the error context and the cleanup tasks only when they are accessed. - * The engine uses it for snapshots it only needs to decide what to claim. + * A store may load the error context and the cleanup tasks of the found subscriptions only when they are accessed. * * @return list */ - public function findLazy(SubscriptionCriteria $criteria): array; - - /** Claims one subscription via a row lock (SKIP LOCKED); null if held by another worker or no match. */ - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null; + public function find(SubscriptionCriteria|null $criteria = null): array; /** - * Claims several subscriptions at once via row locks (SKIP LOCKED), leaving out the ones that are held by another - * worker or no longer match. + * Claims the subscriptions via row locks (SKIP LOCKED), leaving out the ones that are held by another worker or no + * longer match the criteria. * * @param non-empty-list $ids * * @return list */ - public function claimAll(array $ids, SubscriptionCriteria $criteria): array; + public function claim(array $ids, SubscriptionCriteria $criteria): array; /** @throws SubscriptionAlreadyExists */ - public function add(Subscription $subscription): void; - - /** - * Adds several subscriptions at once. - * - * @param non-empty-list $subscriptions - * - * @throws SubscriptionAlreadyExists - */ - public function addAll(array $subscriptions): void; + public function add(Subscription ...$subscriptions): void; /** @throws SubscriptionNotFound */ - public function update(Subscription $subscription): void; - - /** - * Updates several subscriptions at once. - * - * @param non-empty-list $subscriptions - * - * @throws SubscriptionNotFound - */ - public function updateAll(array $subscriptions): void; - - /** - * Removes the subscription together with its inbox. - * - * @throws SubscriptionNotFound - */ - public function remove(Subscription $subscription): void; - - /** - * Adds message indices to the inboxes of partitions. The main subscription of a partitioned subscriber routes every - * message exactly once, the partition then only processes the messages in its inbox. - * - * @param array> $indices subscription id => message indices - */ - public function addToInbox(array $indices): void; + public function update(Subscription ...$subscriptions): void; - /** - * Returns at most $limit indices per subscription after the given index, in ascending order. - * Subscriptions with an empty inbox are left out. - * - * @param array $afterIndex subscription id => index after which the inbox is read - * @param positive-int $limit - * - * @return array> - */ - public function inbox(array $afterIndex, int $limit): array; - - /** - * Removes all indices up to and including the given index from the inboxes. - * - * @param array $toIndex subscription id => index up to which the inbox is cleared - */ - public function removeFromInbox(array $toIndex): void; + public function remove(Subscription ...$subscriptions): void; /** * @param Closure():T $closure diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 7e7471217..0d391cbc9 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1029,6 +1029,8 @@ public function testFindHydratesAllFields(): void $found = $store->find(new SubscriptionCriteria(ids: ['foo'])); self::assertCount(1, $found); + self::assertTrue((new ReflectionClass(Subscription::class))->isUninitializedLazyObject($found[0]), 'the cleanup tasks are loaded lazily'); + self::assertEquals([new DropTableTask('baz')], $found[0]->cleanupTasks()); self::assertEquals($subscription, $found[0]); } @@ -1133,6 +1135,11 @@ public function testInbox(): void { $store = $this->createStore(); + $main = new Subscription('profile', status: Status::Active, position: 0); + $a = Subscription::partition($main, 'a', 0); + $b = Subscription::partition($main, 'b', 0); + $store->add($main, $a, $b); + $store->addToInbox(['profile#a' => [5, 3, 9], 'profile#b' => [4]]); self::assertSame(['profile#a' => [3, 5, 9]], $store->inbox(['profile#a' => -1], 10)); @@ -1144,9 +1151,15 @@ public function testInbox(): void ); self::assertSame([], $store->inbox(['profile#c' => -1], 10), 'an empty inbox is left out'); - $store->removeFromInbox(['profile#a' => 5, 'profile#b' => 3]); + $a->changePosition(5); + $b->changePosition(3); + $store->update($a, $b); - self::assertSame(['profile#a' => [9], 'profile#b' => [4]], $store->inbox(['profile#a' => -1, 'profile#b' => -1], 10)); + self::assertSame( + ['profile#a' => [9], 'profile#b' => [4]], + $store->inbox(['profile#a' => -1, 'profile#b' => -1], 10), + 'updating a partition clears its inbox up to its position', + ); } public function testPendingPartitionsOnly(): void @@ -1169,8 +1182,8 @@ public function testPendingPartitionsOnly(): void ), ); - self::assertNotNull($store->claim('profile#pending', new SubscriptionCriteria(pendingPartitionsOnly: true))); - self::assertNull($store->claim('profile#empty', new SubscriptionCriteria(pendingPartitionsOnly: true))); + self::assertNotSame([], $store->claim(['profile#pending'], new SubscriptionCriteria(pendingPartitionsOnly: true))); + self::assertSame([], $store->claim(['profile#empty'], new SubscriptionCriteria(pendingPartitionsOnly: true))); } public function testRemoveAlsoRemovesTheInbox(): void @@ -1211,7 +1224,7 @@ public function testClaimAll(): void ['a', 'c'], array_map( static fn (Subscription $s) => $s->id(), - $store->claimAll(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), + $store->claim(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), ), ); } @@ -1224,7 +1237,7 @@ public function testAddAll(): void $main->error('error message'); $partition = Subscription::partition(new Subscription('foo', status: Status::Booting), 'tenant-a', 5); - $store->addAll([$main, $partition, new Subscription('other')]); + $store->add(...[$main, $partition, new Subscription('other')]); self::assertEquals($main, $store->get('foo')); self::assertEquals($partition, $store->get('foo#tenant-a')); @@ -1239,14 +1252,14 @@ public function testAddAllDuplicate(): void $this->expectException(SubscriptionAlreadyExists::class); - $store->addAll([new Subscription('bar'), new Subscription('foo')]); + $store->add(...[new Subscription('bar'), new Subscription('foo')]); } public function testUpdateAll(): void { $store = $this->createStore(); - $store->addAll([ + $store->add(...[ new Subscription('a', status: Status::Active, position: 1), new Subscription('b', status: Status::Active, position: 1), new Subscription('c', status: Status::Active, position: 1), @@ -1261,7 +1274,7 @@ public function testUpdateAll(): void $b = $store->get('b'); $b->error(new RuntimeException('error message')); - $store->updateAll([$a, $b]); + $store->update(...[$a, $b]); $loadedA = $store->get('a'); self::assertSame(42, $loadedA->position()); @@ -1283,11 +1296,11 @@ public function testUpdateAllWithoutChanges(): void { $store = $this->createStore(); - $store->addAll([new Subscription('a'), new Subscription('b')]); + $store->add(...[new Subscription('a'), new Subscription('b')]); // mysql and mariadb report no affected rows if nothing changed - $store->updateAll([$store->get('a'), $store->get('b')]); - $store->updateAll([$store->get('a'), $store->get('b')]); + $store->update(...[$store->get('a'), $store->get('b')]); + $store->update(...[$store->get('a'), $store->get('b')]); self::assertSame(Status::New, $store->get('a')->status()); } @@ -1299,7 +1312,7 @@ public function testUpdateAllUnknownSubscription(): void $this->expectException(SubscriptionNotFound::class); - $store->updateAll([$store->get('a'), new Subscription('unknown')]); + $store->update(...[$store->get('a'), new Subscription('unknown')]); } public function testUpdateAllInChunks(): void @@ -1312,20 +1325,20 @@ public function testUpdateAllInChunks(): void $subscriptions[] = new Subscription('s' . $i, status: Status::Active, position: 0); } - $store->addAll($subscriptions); + $store->add(...$subscriptions); foreach ($subscriptions as $i => $subscription) { $subscription->changePosition($i + 1); } - $store->updateAll($subscriptions); + $store->update(...$subscriptions); self::assertCount(250, $store->find()); self::assertSame(1, $store->get('s0')->position()); self::assertSame(250, $store->get('s249')->position()); } - public function testFindLazyLoadsErrorContextAndCleanupTasksOnAccess(): void + public function testFindLoadsErrorContextAndCleanupTasksOnAccess(): void { $store = $this->createStore(); @@ -1333,7 +1346,7 @@ public function testFindLazyLoadsErrorContextAndCleanupTasksOnAccess(): void $subscription->error(new RuntimeException('error message')); $store->add($subscription); - [$lazy] = $store->findLazy(new SubscriptionCriteria(ids: ['foo'])); + [$lazy] = $store->find(new SubscriptionCriteria(ids: ['foo'])); $reflector = new ReflectionClass(Subscription::class); $errorReflector = new ReflectionClass(SubscriptionError::class); @@ -1359,7 +1372,7 @@ public function testUpdateOfALazySubscriptionKeepsErrorContextAndCleanupTasks(): $subscription->error(new RuntimeException('error message')); $store->add($subscription); - [$lazy] = $store->findLazy(new SubscriptionCriteria(ids: ['foo'])); + [$lazy] = $store->find(new SubscriptionCriteria(ids: ['foo'])); $lazy->changePosition(10); $store->update($lazy); @@ -1373,13 +1386,27 @@ public function testUpdateOfALazySubscriptionKeepsErrorContextAndCleanupTasks(): public function testLazySubscriptionThatWasRemoved(): void { $store = $this->createStore(); - $store->add(new Subscription('foo')); + $store->add(new Subscription('foo', cleanupTasks: [new DropTableTask('baz')])); - [$lazy] = $store->findLazy(new SubscriptionCriteria(ids: ['foo'])); + [$lazy] = $store->find(new SubscriptionCriteria(ids: ['foo'])); $store->remove($store->get('foo')); $this->expectException(SubscriptionNotFound::class); self::assertNull($lazy->cleanupTasks()); } + + public function testFindWithoutErrorContextAndCleanupTasksIsNotLazy(): void + { + $store = $this->createStore(); + + $subscription = new Subscription('foo', status: Status::Active, position: 3); + $subscription->error('error message'); + $store->add($subscription); + + [$found] = $store->find(new SubscriptionCriteria(ids: ['foo'])); + + self::assertFalse((new ReflectionClass(Subscription::class))->isUninitializedLazyObject($found)); + self::assertEquals($subscription, $found, 'nothing to load, so it is a plain object'); + } } diff --git a/tests/Integration/Subscription/SubscriptionTest.php b/tests/Integration/Subscription/SubscriptionTest.php index 40369e62d..1fdc84ee2 100644 --- a/tests/Integration/Subscription/SubscriptionTest.php +++ b/tests/Integration/Subscription/SubscriptionTest.php @@ -71,6 +71,7 @@ use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ProfileProjectionWithCleanup; use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\TestCase; +use ReflectionClass; use RuntimeException; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -1272,7 +1273,7 @@ public function testCleanup(): void cleanupTasks: [new DropTableTask('projection_profile_1')], ), ], - $firstEngine->subscriptions(), + self::loaded($firstEngine->subscriptions()), ); // Run first version @@ -1294,7 +1295,7 @@ public function testCleanup(): void cleanupTasks: [new DropTableTask('projection_profile_1')], ), ], - $firstEngine->subscriptions(), + self::loaded($firstEngine->subscriptions()), ); // deploy second version @@ -1329,7 +1330,7 @@ public function testCleanup(): void lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $firstEngine->subscriptions(), + self::loaded($firstEngine->subscriptions()), ); // switch traffic @@ -1356,7 +1357,7 @@ public function testCleanup(): void lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $secondEngine->subscriptions(), + self::loaded($secondEngine->subscriptions()), ); // shutdown second version (with cleanup) @@ -1374,7 +1375,7 @@ public function testCleanup(): void lastSavedAt: new DateTimeImmutable('2021-01-01T00:00:00'), ), ], - $secondEngine->subscriptions(), + self::loaded($secondEngine->subscriptions()), ); self::assertFalse( @@ -1839,21 +1840,21 @@ public function testSkipLockedClaim(): void $criteria = new SubscriptionCriteria(status: [Status::Active]); $this->connection->beginTransaction(); - $claimedByA = $storeWorkerA->claim('a', $criteria); + $claimedByA = $storeWorkerA->claim(['a'], $criteria)[0] ?? null; self::assertNotNull($claimedByA); self::assertSame('a', $claimedByA->id()); - $lockedClaim = $storeWorkerB->claim('a', $criteria); + $lockedClaim = $storeWorkerB->claim(['a'], $criteria)[0] ?? null; self::assertNull($lockedClaim); - $claimedByB = $storeWorkerB->claim('b', $criteria); + $claimedByB = $storeWorkerB->claim(['b'], $criteria)[0] ?? null; self::assertNotNull($claimedByB); self::assertSame('b', $claimedByB->id()); $this->connection->commit(); - $reclaimed = $storeWorkerB->claim('a', $criteria); + $reclaimed = $storeWorkerB->claim(['a'], $criteria)[0] ?? null; self::assertNotNull($reclaimed); self::assertSame('a', $reclaimed->id()); } @@ -1921,4 +1922,22 @@ public function testEventEmitterWithContainer(): void self::assertNotContains('subscription_emitting', $store->streams()); } + + /** + * Subscriptions with cleanup tasks or an error context are lazy objects, assertEquals does not initialize them. + * + * @param list $subscriptions + * + * @return list + */ + private static function loaded(array $subscriptions): array + { + $reflector = new ReflectionClass(Subscription::class); + + foreach ($subscriptions as $subscription) { + $reflector->initializeLazyObject($subscription); + } + + return $subscriptions; + } } diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 2d059bdfa..009dd1fa5 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -8,13 +8,13 @@ use DateTimeImmutable; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Store\PartitionInboxStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; -use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscription; use PHPUnit\Framework\Assert; use Psr\Clock\ClockInterface; -final class DummySubscriptionStore implements SubscriptionStore +final class DummySubscriptionStore implements PartitionInboxStore { private readonly ClockInterface $clock; @@ -51,17 +51,18 @@ public function find(SubscriptionCriteria|null $criteria = null): array return $this->parentStore->find($criteria); } - /** @return list */ - public function findLazy(SubscriptionCriteria $criteria): array - { - return $this->find($criteria); - } - - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claim(array $ids, SubscriptionCriteria $criteria): array { - $this->claimedIds[] = $id; + foreach ($ids as $id) { + $this->claimedIds[] = $id; + } - return $this->parentStore->claim($id, $criteria); + return $this->parentStore->claim($ids, $criteria); } /** @@ -76,38 +77,31 @@ public function inLock(Closure $closure): mixed return $this->parentStore->inLock($closure); } - public function add(Subscription $subscription): void + public function add(Subscription ...$subscriptions): void { - $this->parentStore->add($subscription); - $this->addedSubscriptions[] = clone $subscription; - } + $this->parentStore->add(...$subscriptions); - /** @param non-empty-list $subscriptions */ - public function addAll(array $subscriptions): void - { foreach ($subscriptions as $subscription) { - $this->add($subscription); + $this->addedSubscriptions[] = clone $subscription; } } - /** @param non-empty-list $subscriptions */ - public function updateAll(array $subscriptions): void + public function update(Subscription ...$subscriptions): void { + $this->parentStore->update(...$subscriptions); + foreach ($subscriptions as $subscription) { - $this->update($subscription); + $this->updatedSubscriptions[] = clone $subscription; } } - public function update(Subscription $subscription): void + public function remove(Subscription ...$subscriptions): void { - $this->parentStore->update($subscription); - $this->updatedSubscriptions[] = clone $subscription; - } + $this->parentStore->remove(...$subscriptions); - public function remove(Subscription $subscription): void - { - $this->parentStore->remove($subscription); - $this->removedSubscriptions[] = clone $subscription; + foreach ($subscriptions as $subscription) { + $this->removedSubscriptions[] = clone $subscription; + } } /** @param array> $indices */ @@ -127,26 +121,6 @@ public function inbox(array $afterIndex, int $limit): array return $this->parentStore->inbox($afterIndex, $limit); } - /** @param array $toIndex */ - public function removeFromInbox(array $toIndex): void - { - $this->parentStore->removeFromInbox($toIndex); - } - - /** - * @param non-empty-list $ids - * - * @return list - */ - public function claimAll(array $ids, SubscriptionCriteria $criteria): array - { - foreach ($ids as $id) { - $this->claimedIds[] = $id; - } - - return $this->parentStore->claimAll($ids, $criteria); - } - public function reset(): void { $this->addedSubscriptions = []; diff --git a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php index aab0fe9f0..9aeefa5cd 100644 --- a/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DiscoverSubscriberTest.php @@ -209,7 +209,7 @@ class { $subscriptionStore ->expects($this->once()) - ->method('findLazy') + ->method('find') ->with(new SubscriptionCriteria()) ->willReturn([new Subscription('id1')]); diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index 19c480c75..968814a9c 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -121,8 +121,8 @@ public function testForEachClaimedProcessesClaimedSubscription(): void $criteria = new SubscriptionCriteria(); $store = $this->createMock(SubscriptionStore::class); - $store->expects($this->once())->method('findLazy')->with($criteria)->willReturn([$subscription]); - $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn($subscription); + $store->expects($this->once())->method('find')->with($criteria)->willReturn([$subscription]); + $store->expects($this->once())->method('claim')->with(['foo'], $criteria)->willReturn([$subscription]); $store->expects($this->once())->method('update')->with($subscription); $store ->expects($this->once()) @@ -140,14 +140,14 @@ public function testForEachClaimedProcessesClaimedSubscription(): void self::assertSame([$subscription], $result); } - public function testForEachClaimedSkipsWhenClaimReturnsNull(): void + public function testForEachClaimedSkipsWhenNothingIsClaimed(): void { $subscription = new Subscription('foo'); $criteria = new SubscriptionCriteria(); $store = $this->createMock(SubscriptionStore::class); - $store->expects($this->once())->method('findLazy')->with($criteria)->willReturn([$subscription]); - $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn(null); + $store->expects($this->once())->method('find')->with($criteria)->willReturn([$subscription]); + $store->expects($this->once())->method('claim')->with(['foo'], $criteria)->willReturn([]); $store->expects($this->never())->method('update'); $store ->expects($this->once()) @@ -166,8 +166,8 @@ public function testForEachClaimedIsolatesTransientErrors(): void $criteria = new SubscriptionCriteria(); $store = $this->createMock(SubscriptionStore::class); - $store->expects($this->once())->method('findLazy')->with($criteria)->willReturn([$subscription]); - $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn($subscription); + $store->expects($this->once())->method('find')->with($criteria)->willReturn([$subscription]); + $store->expects($this->once())->method('claim')->with(['foo'], $criteria)->willReturn([$subscription]); $store ->expects($this->once()) ->method('inLock') diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index 5b039c33e..f1851eb28 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -193,12 +193,14 @@ public function testFind(): void 'cleanup_tasks' => null, 'subscriber_id' => 'foo', 'partition_key' => null, + 'has_error_context' => 0, + 'has_cleanup_tasks' => 0, ], ]); $connection ->expects($this->once()) ->method('executeQuery') - ->with('SELECT * FROM subscriptions ORDER BY id', [], $this->isArray()) + ->with('SELECT id, group_name, run_mode, position, status, error_message, error_previous_status, retry_attempt, last_saved_at, subscriber_id, partition_key, CASE WHEN error_context IS NULL THEN 0 ELSE 1 END AS has_error_context, CASE WHEN cleanup_tasks IS NULL THEN 0 ELSE 1 END AS has_cleanup_tasks FROM subscriptions ORDER BY id', [], $this->isArray()) ->willReturn($result); $store = new DoctrineSubscriptionStore($connection); @@ -231,7 +233,7 @@ public function testFindWithCriteria(): void ->expects($this->once()) ->method('executeQuery') ->with( - 'SELECT * FROM subscriptions WHERE (id IN (:ids) OR subscriber_id IN (:subscriber_ids)) AND (group_name IN (:groups)) AND (status IN (:status)) ORDER BY id', + 'SELECT id, group_name, run_mode, position, status, error_message, error_previous_status, retry_attempt, last_saved_at, subscriber_id, partition_key, CASE WHEN error_context IS NULL THEN 0 ELSE 1 END AS has_error_context, CASE WHEN cleanup_tasks IS NULL THEN 0 ELSE 1 END AS has_cleanup_tasks FROM subscriptions WHERE (id IN (:ids) OR subscriber_id IN (:subscriber_ids)) AND (group_name IN (:groups)) AND (status IN (:status)) ORDER BY id', [ 'ids' => ['foo'], 'subscriber_ids' => ['foo'], @@ -263,29 +265,31 @@ public function testClaim(): void $result = $this->createMock(Result::class); $result ->expects($this->once()) - ->method('fetchAssociative') + ->method('fetchAllAssociative') ->willReturn([ - 'id' => 'foo', - 'group_name' => 'default', - 'run_mode' => 'from_beginning', - 'position' => null, - 'status' => 'active', - 'error_message' => null, - 'error_previous_status' => null, - 'error_context' => null, - 'retry_attempt' => 0, - 'last_saved_at' => '2024-01-01 10:00:00', - 'cleanup_tasks' => null, - 'subscriber_id' => 'foo', - 'partition_key' => null, + [ + 'id' => 'foo', + 'group_name' => 'default', + 'run_mode' => 'from_beginning', + 'position' => null, + 'status' => 'active', + 'error_message' => null, + 'error_previous_status' => null, + 'error_context' => null, + 'retry_attempt' => 0, + 'last_saved_at' => '2024-01-01 10:00:00', + 'cleanup_tasks' => null, + 'subscriber_id' => 'foo', + 'partition_key' => null, + ], ]); $connection ->expects($this->once()) ->method('executeQuery') ->with( - 'SELECT * FROM subscriptions WHERE (id = :id) AND (status IN (:status))', + 'SELECT * FROM subscriptions WHERE (id IN (:claim_ids)) AND (status IN (:status)) ORDER BY id', [ - 'id' => 'foo', + 'claim_ids' => ['foo'], 'status' => ['active'], ], $this->isArray(), @@ -294,10 +298,10 @@ public function testClaim(): void $store = new DoctrineSubscriptionStore($connection); - $subscription = $store->claim('foo', new SubscriptionCriteria(status: [Status::Active])); + $subscriptions = $store->claim(['foo'], new SubscriptionCriteria(status: [Status::Active])); - self::assertInstanceOf(Subscription::class, $subscription); - self::assertSame('foo', $subscription->id()); + self::assertCount(1, $subscriptions); + self::assertSame('foo', $subscriptions[0]->id()); } public function testClaimNotFound(): void @@ -316,8 +320,8 @@ public function testClaimNotFound(): void $result = $this->createMock(Result::class); $result ->expects($this->once()) - ->method('fetchAssociative') - ->willReturn(false); + ->method('fetchAllAssociative') + ->willReturn([]); $connection ->expects($this->once()) ->method('executeQuery') @@ -325,7 +329,7 @@ public function testClaimNotFound(): void $store = new DoctrineSubscriptionStore($connection); - self::assertNull($store->claim('foo', new SubscriptionCriteria(status: [Status::Active]))); + self::assertSame([], $store->claim(['foo'], new SubscriptionCriteria(status: [Status::Active]))); } public function testAdd(): void diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index 218bc0ca5..94da08f0b 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -183,8 +183,8 @@ public function testClaim(): void $store = new InMemorySubscriptionStore([$subscription]); self::assertSame( - $subscription, - $store->claim('foo', new SubscriptionCriteria(status: [Status::Active])), + [$subscription], + $store->claim(['foo'], new SubscriptionCriteria(status: [Status::Active])), ); } @@ -192,7 +192,7 @@ public function testClaimNotFound(): void { $store = new InMemorySubscriptionStore(); - self::assertNull($store->claim('foo', new SubscriptionCriteria())); + self::assertSame([], $store->claim(['foo'], new SubscriptionCriteria())); } public function testClaimCriteriaNotMatched(): void @@ -201,7 +201,7 @@ public function testClaimCriteriaNotMatched(): void $store = new InMemorySubscriptionStore([$subscription]); - self::assertNull($store->claim('foo', new SubscriptionCriteria(status: [Status::Active]))); + self::assertSame([], $store->claim(['foo'], new SubscriptionCriteria(status: [Status::Active]))); } public function testInLock(): void @@ -253,6 +253,11 @@ public function testInbox(): void { $store = new InMemorySubscriptionStore(); + $main = new Subscription('profile', status: Status::Active, position: 0); + $a = Subscription::partition($main, 'a', 0); + $b = Subscription::partition($main, 'b', 0); + $store->add($main, $a, $b); + $store->addToInbox(['profile#a' => [5, 3, 9], 'profile#b' => [4]]); self::assertSame(['profile#a' => [3, 5, 9]], $store->inbox(['profile#a' => -1], 10)); @@ -264,9 +269,15 @@ public function testInbox(): void ); self::assertSame([], $store->inbox(['profile#c' => -1], 10), 'an empty inbox is left out'); - $store->removeFromInbox(['profile#a' => 5, 'profile#b' => 3]); + $a->changePosition(5); + $b->changePosition(3); + $store->update($a, $b); - self::assertSame(['profile#a' => [9], 'profile#b' => [4]], $store->inbox(['profile#a' => -1, 'profile#b' => -1], 10)); + self::assertSame( + ['profile#a' => [9], 'profile#b' => [4]], + $store->inbox(['profile#a' => -1, 'profile#b' => -1], 10), + 'updating a partition clears its inbox up to its position', + ); } public function testPendingPartitionsOnly(): void @@ -289,8 +300,8 @@ public function testPendingPartitionsOnly(): void ), ); - self::assertNotNull($store->claim('profile#pending', new SubscriptionCriteria(pendingPartitionsOnly: true))); - self::assertNull($store->claim('profile#empty', new SubscriptionCriteria(pendingPartitionsOnly: true))); + self::assertNotSame([], $store->claim(['profile#pending'], new SubscriptionCriteria(pendingPartitionsOnly: true))); + self::assertSame([], $store->claim(['profile#empty'], new SubscriptionCriteria(pendingPartitionsOnly: true))); } public function testRemoveAlsoRemovesTheInbox(): void @@ -319,7 +330,7 @@ public function testClaimAll(): void ['a', 'c'], array_map( static fn (Subscription $s) => $s->id(), - $store->claimAll(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), + $store->claim(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), ), ); } @@ -328,11 +339,11 @@ public function testAddAllAndUpdateAll(): void { $store = new InMemorySubscriptionStore(); - $store->addAll([new Subscription('a'), new Subscription('b')]); + $store->add(...[new Subscription('a'), new Subscription('b')]); $a = $store->get('a'); $a->changePosition(5); - $store->updateAll([$a, $store->get('b')]); + $store->update(...[$a, $store->get('b')]); self::assertSame(5, $store->get('a')->position()); self::assertCount(2, $store->find()); From 29a5cedd73cd7697b37b10087b30f0f38f621115 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 09:34:07 +0200 Subject: [PATCH 11/57] Keep MessageLoader and the claim API small loadIndices() moved out of MessageLoader into the optional IndexMessageLoader, which the built-in loaders implement. Without it, or when the event store does not know the IndexCriterion, a partition reads the range of its inbox and skips the other messages. A custom message loader or event store therefore needs nothing new. The run only filters for pending partitions when the subscription store has an inbox, so other stores never see that criterion. The router adds to the inbox before it creates a partition, so a store without an inbox fails without leaving a partition behind. Internally, forEachClaimedGroup() takes a ClaimGroups object instead of three loose numbers, and the retry filter only exists on forEachClaimed(). --- docs/UPGRADE-4.0.md | 27 ------- docs/subscription.md | 2 + src/Subscription/Engine/ClaimGroups.php | 42 ++++++++++ .../EventFilteredStoreMessageLoader.php | 2 +- .../Engine/GapResolverStoreMessageLoader.php | 2 +- .../Engine/Handler/BootHandler.php | 11 ++- .../Engine/Handler/RunHandler.php | 14 ++-- .../Engine/IndexMessageLoader.php | 21 +++++ src/Subscription/Engine/MessageLoader.php | 7 -- src/Subscription/Engine/PartitionRouter.php | 4 +- .../Engine/StoreMessageLoader.php | 2 +- .../Engine/SubscriptionManager.php | 81 ++++++++++--------- .../Engine/SubscriptionRunner.php | 37 ++++++--- .../Store/SubscriptionCriteria.php | 3 +- ...ndexCriterionNotSupportedMessageLoader.php | 38 +++++++++ .../PartitionedSubscriptionTest.php | 66 ++++++++++++++- .../Subscription/PlainMessageLoader.php | 29 +++++++ .../Subscription/PlainSubscriptionStore.php | 67 +++++++++++++++ .../Engine/SubscriptionManagerTest.php | 17 ++-- 19 files changed, 365 insertions(+), 107 deletions(-) create mode 100644 src/Subscription/Engine/ClaimGroups.php create mode 100644 src/Subscription/Engine/IndexMessageLoader.php create mode 100644 tests/Integration/Subscription/IndexCriterionNotSupportedMessageLoader.php create mode 100644 tests/Integration/Subscription/PlainMessageLoader.php create mode 100644 tests/Integration/Subscription/PlainSubscriptionStore.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index f0b2dc421..27c84b6a9 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -318,33 +318,6 @@ final class CustomMessageLoader implements MessageLoader } } ``` -A message loader also has to implement `loadIndices()`, which loads only the messages with the given indices. -Partitions use it to read just the messages in their inbox. With a store, it is a load with the new `IndexCriterion`: - -```php -use Patchlevel\EventSourcing\Message\Stream; -use Patchlevel\EventSourcing\Store\Criteria\Criteria; -use Patchlevel\EventSourcing\Store\Criteria\IndexCriterion; -use Patchlevel\EventSourcing\Store\Store; -use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; - -final class CustomMessageLoader implements MessageLoader -{ - public function __construct( - private readonly Store $store, - ) { - } - - /** @param non-empty-list $indices */ - public function loadIndices(array $indices): Stream - { - return $this->store->load(new Criteria(new IndexCriterion(...$indices))); - } - - // ... -} -``` -A custom store has to support the `IndexCriterion` as well. ### Batchable Subscriber The `Patchlevel\EventSourcing\Subscription\Subscriber\BatchableSubscriber` interface has been removed. diff --git a/docs/subscription.md b/docs/subscription.md index 41354febd..0101f9614 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -886,6 +886,8 @@ Partitions are created on the fly as new keys appear, get the id `{subscriber id and start with the group, run mode and status of the main subscription. A partition only processes the messages in its inbox and is only claimed by a worker if its inbox has messages, so idle partitions cost nothing. +The built-in message loaders load exactly these messages. A custom message loader can do the same by implementing +`IndexMessageLoader`, otherwise a partition reads the range of its inbox and skips the other messages. Setup, teardown and cleanup belong to the subscriber as a whole and only run on the main subscription. Removing the main subscription removes its partitions as well. diff --git a/src/Subscription/Engine/ClaimGroups.php b/src/Subscription/Engine/ClaimGroups.php new file mode 100644 index 000000000..84222cf43 --- /dev/null +++ b/src/Subscription/Engine/ClaimGroups.php @@ -0,0 +1,42 @@ + $retriesInMs in milliseconds */ public function __construct( diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 828007b71..56d673ea7 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Error; @@ -39,8 +40,12 @@ public function __invoke(Command $command): ProcessedResult groups: $command->groups, status: [Status::Booting], ), - $command->partitionsPerClaim, - $command->subscriptionsPerClaim, + new ClaimGroups( + $command->subscriptionsPerClaim, + $command->partitionsPerClaim, + // the group reads the stream from its lowest position, a larger gap would use up the limit of the others + $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, + ), fn (array $subscriptions): ProcessedResult => $this->runner->process( $subscriptions, $command->limit, @@ -54,8 +59,6 @@ public function __invoke(Command $command): ProcessedResult $subscriptions, ), ), - // the group reads the stream from its lowest position, a larger gap would use up the limit of the others - $command->limit ?? SubscriptionManager::DEFAULT_POSITION_WINDOW, ); return ProcessedResult::merge($results); diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index 8af719bb4..d8ceec973 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Error; @@ -38,10 +39,15 @@ public function __invoke(Command $command): ProcessedResult ids: $command->ids, groups: $command->groups, status: [Status::Active], - pendingPartitionsOnly: true, + // only a store with an inbox has partitions, every other store does not need to know the filter + pendingPartitionsOnly: $this->subscriptionManager->supportsPartitions(), + ), + new ClaimGroups( + $command->subscriptionsPerClaim, + $command->partitionsPerClaim, + // the group reads the stream from its lowest position, a larger gap would use up the limit of the others + $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, ), - $command->partitionsPerClaim, - $command->subscriptionsPerClaim, fn (array $subscriptions): ProcessedResult => $this->runner->process( $subscriptions, $command->limit, @@ -54,8 +60,6 @@ public function __invoke(Command $command): ProcessedResult $subscriptions, ), ), - // the group reads the stream from its lowest position, a larger gap would use up the limit of the others - $command->limit ?? SubscriptionManager::DEFAULT_POSITION_WINDOW, ); return ProcessedResult::merge($results); diff --git a/src/Subscription/Engine/IndexMessageLoader.php b/src/Subscription/Engine/IndexMessageLoader.php new file mode 100644 index 000000000..9e84e99ab --- /dev/null +++ b/src/Subscription/Engine/IndexMessageLoader.php @@ -0,0 +1,21 @@ + $indices + */ + public function loadIndices(array $indices): Stream; +} diff --git a/src/Subscription/Engine/MessageLoader.php b/src/Subscription/Engine/MessageLoader.php index d147cf181..01f5a860f 100644 --- a/src/Subscription/Engine/MessageLoader.php +++ b/src/Subscription/Engine/MessageLoader.php @@ -12,12 +12,5 @@ interface MessageLoader /** @param list $subscriptions */ public function load(int|null $startIndex, array $subscriptions): Stream; - /** - * Loads only the messages with the given indices in ascending order, e.g. the inbox of a partition. - * - * @param non-empty-list $indices - */ - public function loadIndices(array $indices): Stream; - public function lastIndex(): int; } diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index bf68a1a77..d11e85b8e 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -55,6 +55,9 @@ public function route(int $index, Message $message, Subscription $main, Metadata $partitionKey = $this->partitionKey($message, $main, $subscriber); $partitionId = Subscription::partitionId($main->id(), $partitionKey); + // fails first if the store has no inbox, so no partition is created that could never be processed + $this->subscriptionManager->addToInbox($partitionId, $index); + if (!$this->exists($main, $partitionId)) { $this->subscriptionManager->add(Subscription::partition($main, $partitionKey, $index - 1)); $this->knownPartitions[$partitionId] = true; @@ -66,7 +69,6 @@ public function route(int $index, Message $message, Subscription $main, Metadata )); } - $this->subscriptionManager->addToInbox($partitionId, $index); $this->routed = true; } diff --git a/src/Subscription/Engine/StoreMessageLoader.php b/src/Subscription/Engine/StoreMessageLoader.php index e39677ccc..9fb46fa70 100644 --- a/src/Subscription/Engine/StoreMessageLoader.php +++ b/src/Subscription/Engine/StoreMessageLoader.php @@ -11,7 +11,7 @@ use Patchlevel\EventSourcing\Store\Store; use Patchlevel\EventSourcing\Subscription\Subscription; -final class StoreMessageLoader implements MessageLoader +final class StoreMessageLoader implements IndexMessageLoader { public function __construct( private readonly Store $store, diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 93a31b0c5..2c0d6b53c 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -25,9 +25,6 @@ /** @internal */ final class SubscriptionManager { - /** Subscriptions further apart than that are not processed together, if there is no message limit. */ - public const DEFAULT_POSITION_WINDOW = 1000; - /** @var SplObjectStorage */ private SplObjectStorage $forAdd; @@ -50,6 +47,8 @@ public function __construct( } /** + * Claims every subscription alone in its own transaction. + * * @param Closure(Subscription):T $closure * @param Closure(Subscription, Throwable):T $onError * @param Closure(Subscription):bool $filter decides on the unclaimed snapshot whether to claim at all @@ -64,6 +63,12 @@ public function forEachClaimed( Closure|null $onError = null, Closure|null $filter = null, ): array { + $snapshot = $this->subscriptionStore->find($criteria); + + if ($filter !== null) { + $snapshot = array_values(array_filter($snapshot, $filter)); + } + $groupOnError = null; if ($onError !== null) { @@ -73,16 +78,14 @@ public function forEachClaimed( }; } - return $this->forEachClaimedGroup( + return $this->claimEach( + $this->groups($snapshot, new ClaimGroups()), $criteria, - 1, - 1, static function (array $subscriptions) use ($closure): mixed { /** @var non-empty-list $subscriptions */ return $closure($subscriptions[0]); }, $groupOnError, - filter: $filter, ); } @@ -92,14 +95,6 @@ static function (array $subscriptions) use ($closure): mixed { * inbox. A subscription that is locked by another worker is left out of its group. * If the closure throws, every subscription of the group goes into the error status. * - * Subscriptions are only grouped if their positions are at most $positionWindow apart, the group reads the stream - * from its lowest position and a subscription far behind would hold back the others. - * - * @param positive-int $partitionsPerClaim - * @param positive-int $subscriptionsPerClaim - * @param positive-int $positionWindow - * @param Closure(Subscription):bool $filter decides on the unclaimed snapshot whether to - * claim at all, the closure checks again * @param Closure(non-empty-list):T $closure * @param Closure(non-empty-list, Throwable):T $onError * @@ -109,26 +104,30 @@ static function (array $subscriptions) use ($closure): mixed { */ public function forEachClaimedGroup( SubscriptionCriteria $criteria, - int $partitionsPerClaim, - int $subscriptionsPerClaim, + ClaimGroups $groups, Closure $closure, Closure|null $onError = null, - int $positionWindow = self::DEFAULT_POSITION_WINDOW, - Closure|null $filter = null, ): array { - $results = []; - $snapshot = $this->subscriptionStore->find($criteria); - - if ($filter !== null) { - $snapshot = array_values(array_filter($snapshot, $filter)); - } - - $groups = $this->groups( - $snapshot, - $partitionsPerClaim, - $subscriptionsPerClaim, - $positionWindow, + return $this->claimEach( + $this->groups($this->subscriptionStore->find($criteria), $groups), + $criteria, + $closure, + $onError, ); + } + + /** + * @param list> $groups + * @param Closure(non-empty-list):T $closure + * @param Closure(non-empty-list, Throwable):T $onError + * + * @return list + * + * @template T + */ + private function claimEach(array $groups, SubscriptionCriteria $criteria, Closure $closure, Closure|null $onError): array + { + $results = []; foreach ($groups as $candidates) { $skipped = false; @@ -215,18 +214,15 @@ private function claim(array $candidates, SubscriptionCriteria $criteria): array * subscriptions close to each other read the stream together. Without grouping, the order of the snapshot is kept. * * @param list $subscriptions - * @param positive-int $partitionsPerClaim - * @param positive-int $subscriptionsPerClaim - * @param positive-int $positionWindow * * @return list> */ - private function groups( - array $subscriptions, - int $partitionsPerClaim, - int $subscriptionsPerClaim, - int $positionWindow, - ): array { + private function groups(array $subscriptions, ClaimGroups $claimGroups): array + { + $partitionsPerClaim = $claimGroups->partitions; + $subscriptionsPerClaim = $claimGroups->subscriptions; + $positionWindow = $claimGroups->positionWindow; + $groups = []; $partitions = []; $others = []; @@ -348,6 +344,11 @@ public function inbox(array $afterIndex, int $limit): array return $this->inboxStore()->inbox($afterIndex, $limit); } + public function supportsPartitions(): bool + { + return $this->subscriptionStore instanceof PartitionInboxStore; + } + private function inboxStore(): PartitionInboxStore { if (!$this->subscriptionStore instanceof PartitionInboxStore) { diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 10c1f1da1..129c266da 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; +use Patchlevel\EventSourcing\Message\Stream; +use Patchlevel\EventSourcing\Store\UnsupportedCriterion; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptionProcessed; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; @@ -84,17 +86,13 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals if (count($working) > 0) { [$startIndex, $endIndex] = $this->range($working, $inboxes); $indices = $this->inboxIndices($working, $inboxes); + // only partitions, then the limit is already applied to every single inbox + $onlyPartitions = $endIndex !== null; try { - if ($indices !== null) { - // only partitions, so only the messages of their inboxes have to be loaded - $this->logger?->debug(sprintf( - 'Subscription Engine: %d messages of the inbox are processed.', - count($indices), - )); + $stream = $indices !== null ? $this->loadIndices($indices) : null; - $stream = $this->messageLoader->loadIndices($indices); - } else { + if ($stream === null) { $this->logger?->debug(sprintf( 'Subscription Engine: Event stream is processed from position %s.', $startIndex ?? 'beginning', @@ -137,7 +135,7 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals } // the inbox of every partition is already limited, a shared limit would starve the others - if ($indices === null && $limit !== null && $messageCounter >= $limit) { + if (!$onlyPartitions && $limit !== null && $messageCounter >= $limit) { $this->logger?->info(sprintf( 'Subscription Engine: Message limit (%d) reached, finish processing.', $limit, @@ -309,6 +307,27 @@ private function range(SubscriptionCollection $working, array $inboxes): array return [$startIndex, $bounded ? $endIndex : null]; } + /** + * Only the messages of the inboxes are loaded if the loader and the store can do it, + * otherwise the partitions read the range of their inboxes and skip the other messages. + * + * @param non-empty-list $indices + */ + private function loadIndices(array $indices): Stream|null + { + if (!$this->messageLoader instanceof IndexMessageLoader) { + return null; + } + + $this->logger?->debug(sprintf('Subscription Engine: %d messages of the inbox are processed.', count($indices))); + + try { + return $this->messageLoader->loadIndices($indices); + } catch (UnsupportedCriterion) { + return null; + } + } + /** * The indices of all inboxes, or null if a subscription has to read the stream. * diff --git a/src/Subscription/Store/SubscriptionCriteria.php b/src/Subscription/Store/SubscriptionCriteria.php index 8440a16a8..5aed924da 100644 --- a/src/Subscription/Store/SubscriptionCriteria.php +++ b/src/Subscription/Store/SubscriptionCriteria.php @@ -15,7 +15,8 @@ final class SubscriptionCriteria * @param list|null $groups * @param list|null $status * @param bool|null $partitions true only matches partitions, false only the main subscriptions - * @param bool $pendingPartitionsOnly partitions only match if their inbox has messages after their position + * @param bool $pendingPartitionsOnly partitions only match if their inbox has messages after their position, + * only used with a PartitionInboxStore */ public function __construct( public readonly array|null $ids = null, diff --git a/tests/Integration/Subscription/IndexCriterionNotSupportedMessageLoader.php b/tests/Integration/Subscription/IndexCriterionNotSupportedMessageLoader.php new file mode 100644 index 000000000..54b6569ce --- /dev/null +++ b/tests/Integration/Subscription/IndexCriterionNotSupportedMessageLoader.php @@ -0,0 +1,38 @@ + $subscriptions */ + public function load(int|null $startIndex, array $subscriptions): Stream + { + return $this->loader->load($startIndex, $subscriptions); + } + + /** @param non-empty-list $indices */ + public function loadIndices(array $indices): Stream + { + throw new UnsupportedCriterion(IndexCriterion::class); + } + + public function lastIndex(): int + { + return $this->loader->lastIndex(); + } +} diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index ca11eac30..a9015ad62 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -311,7 +311,8 @@ public function testBatchSubscriberWithPartitions(): void } } - private function engine(object $subscriber): SubscriptionEngine + /** @param 'index'|'plain'|'unsupported' $messageLoader */ + private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { $store = new StreamDoctrineDbalStore( $this->connection, @@ -332,9 +333,15 @@ private function engine(object $subscriber): SubscriptionEngine $subscriberRepository = new MetadataSubscriberAccessorRepository([$subscriber]); + $loader = new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository); + return new DefaultSubscriptionEngine( - new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), - $this->subscriptionStore, + match ($messageLoader) { + 'index' => $loader, + 'plain' => new PlainMessageLoader($loader), + 'unsupported' => new IndexCriterionNotSupportedMessageLoader($loader), + }, + $inboxStore ? $this->subscriptionStore : new PlainSubscriptionStore($this->subscriptionStore), $subscriberRepository, new RetryStrategyRepository([ RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy($this->clock), @@ -513,4 +520,57 @@ public function testFailedPartitionKeepsOnlyTheUnprocessedMessagesInItsInbox(): self::assertSame(['n1', 'n2', 'broken', 'n4'], $projection->processed[$id->toString()]); self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10)); } + + /** @return iterable */ + public static function messageLoadersWithoutIndexSupport(): iterable + { + yield 'loader without IndexMessageLoader' => ['plain']; + yield 'store without IndexCriterion' => ['unsupported']; + } + + /** @param 'plain'|'unsupported' $messageLoader */ + #[DataProvider('messageLoadersWithoutIndexSupport')] + public function testMessageLoaderWithoutIndexSupport(string $messageLoader): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection, $messageLoader); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(3); + + foreach ($ids as $id) { + $this->changeName(ProfileId::fromString($id), 'changed-' . $id); + } + + $this->runUntilFinished($engine); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i, 'changed-' . $id], $projection->processed[$id], 'the partitions read the range of their inbox'); + } + + foreach ($this->partitions() as $partition) { + self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10), $partition->id()); + } + } + + public function testSubscriptionStoreWithoutInbox(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection, inboxStore: false); + + $engine->execute(new Setup(skipBooting: true)); + $this->createProfiles(1); + + $engine->execute(new Run()); + + $main = $this->subscriptionStore->get(self::MAIN); + + $error = $main->subscriptionError(); + + self::assertSame(Status::Error, $main->status()); + self::assertNotNull($error); + self::assertStringContainsString('does not support partitions', $error->errorMessage); + self::assertSame([], $this->partitions()); + } } diff --git a/tests/Integration/Subscription/PlainMessageLoader.php b/tests/Integration/Subscription/PlainMessageLoader.php new file mode 100644 index 000000000..786007377 --- /dev/null +++ b/tests/Integration/Subscription/PlainMessageLoader.php @@ -0,0 +1,29 @@ + $subscriptions */ + public function load(int|null $startIndex, array $subscriptions): Stream + { + return $this->loader->load($startIndex, $subscriptions); + } + + public function lastIndex(): int + { + return $this->loader->lastIndex(); + } +} diff --git a/tests/Integration/Subscription/PlainSubscriptionStore.php b/tests/Integration/Subscription/PlainSubscriptionStore.php new file mode 100644 index 000000000..2589ce3db --- /dev/null +++ b/tests/Integration/Subscription/PlainSubscriptionStore.php @@ -0,0 +1,67 @@ +store->get($subscriptionId); + } + + /** @return list */ + public function find(SubscriptionCriteria|null $criteria = null): array + { + return $this->store->find($criteria); + } + + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claim(array $ids, SubscriptionCriteria $criteria): array + { + return $this->store->claim($ids, $criteria); + } + + public function add(Subscription ...$subscriptions): void + { + $this->store->add(...$subscriptions); + } + + public function update(Subscription ...$subscriptions): void + { + $this->store->update(...$subscriptions); + } + + public function remove(Subscription ...$subscriptions): void + { + $this->store->remove(...$subscriptions); + } + + /** + * @param Closure():T $closure + * + * @return T + * + * @template T + */ + public function inLock(Closure $closure): mixed + { + return $this->store->inLock($closure); + } +} diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index 968814a9c..a7a08bda2 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; use Closure; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; @@ -200,8 +201,7 @@ public function testForEachClaimedGroupKeepsPartitionsAndSubscriptionsApart(): v $groups = $manager->forEachClaimedGroup( new SubscriptionCriteria(), - 2, - 2, + new ClaimGroups(subscriptions: 2, partitions: 2), static fn (array $subscriptions): array => array_map( static fn (Subscription $subscription): string => $subscription->id(), $subscriptions, @@ -222,8 +222,7 @@ public function testForEachClaimedGroupPutsTheWholeGroupIntoError(): void $results = $manager->forEachClaimedGroup( new SubscriptionCriteria(), - 1, - 2, + new ClaimGroups(subscriptions: 2), static fn (array $subscriptions): never => throw new RuntimeException('boom'), static fn (array $subscriptions, Throwable $e): int => count($subscriptions), ); @@ -248,13 +247,11 @@ public function testForEachClaimedGroupOnlyGroupsSubscriptionsWithClosePositions $groups = $manager->forEachClaimedGroup( new SubscriptionCriteria(), - 1, - 10, + new ClaimGroups(subscriptions: 10, positionWindow: 1000), static fn (array $subscriptions): array => array_map( static fn (Subscription $subscription): string => $subscription->id(), $subscriptions, ), - positionWindow: 1000, ); self::assertSame( @@ -277,4 +274,10 @@ public function testForEachClaimedKeepsTheOrderOfTheSnapshot(): void $manager->forEachClaimed(new SubscriptionCriteria(), static fn (Subscription $subscription): string => $subscription->id()), ); } + + public function testSupportsPartitionsOnlyWithAnInboxStore(): void + { + self::assertFalse((new SubscriptionManager($this->createMock(SubscriptionStore::class)))->supportsPartitions()); + self::assertTrue((new SubscriptionManager(new InMemorySubscriptionStore()))->supportsPartitions()); + } } From f74cf53f6cb5b6f13636a642846ce431681e473b Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 10:05:55 +0200 Subject: [PATCH 12/57] Stop a partitioned subscriber on a systemic failure When the database of a projection is down, every partition with work failed, used up its retries and ended up failed, and a subscriber with an OnFailed method even skipped a message in each of them. A new listener counts the failing partitions of a subscriber during a run. If #[Partition(maxConsecutiveFailures)] of them (default 5) fail in a row without a success in between, the main subscription goes into the error status, the partitions that failed are set back and the others of the run skip their messages via the new OnHandleMessage::$skip. While the main subscription is in error, its partitions are not processed at all, so they continue where they were once it is retried. Routing no longer resets the retries of the main subscription, otherwise it would never fail during a long outage. The listener resets them once partitions succeed again. --- docs/subscription.md | 33 +++ src/Attribute/Partition.php | 11 + .../AttributeSubscriberMetadataFactory.php | 11 +- src/Metadata/Subscriber/PartitionMetadata.php | 15 + .../Subscriber/SubscriberMetadata.php | 2 +- .../Engine/DefaultSubscriptionEngine.php | 10 + .../Engine/Event/OnHandleMessage.php | 2 + .../Engine/Listener/BatchSubscriber.php | 5 + .../Listener/PartitionCircuitBreaker.php | 262 ++++++++++++++++++ .../Engine/Listener/RetrySubscriber.php | 6 + src/Subscription/Engine/MessageProcessor.php | 12 +- .../Engine/SubscriptionRunner.php | 62 +++++ .../Subscriber/MetadataSubscriberAccessor.php | 2 +- .../PartitionedSubscriptionTest.php | 102 +++++++ ...AttributeSubscriberMetadataFactoryTest.php | 21 +- .../Listener/PartitionCircuitBreakerTest.php | 196 +++++++++++++ .../Engine/Listener/RetrySubscriberTest.php | 29 ++ .../Engine/MessageProcessorTest.php | 35 +++ 18 files changed, 808 insertions(+), 8 deletions(-) create mode 100644 src/Metadata/Subscriber/PartitionMetadata.php create mode 100644 src/Subscription/Engine/Listener/PartitionCircuitBreaker.php create mode 100644 tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php diff --git a/docs/subscription.md b/docs/subscription.md index 0101f9614..55d53c540 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -891,6 +891,7 @@ The built-in message loaders load exactly these messages. A custom message loade Setup, teardown and cleanup belong to the subscriber as a whole and only run on the main subscription. Removing the main subscription removes its partitions as well. +If the main subscription is in error, the whole subscriber waits until it is retried. :::warning The order of messages is only guaranteed within a partition, never across partitions. @@ -907,6 +908,38 @@ Changing the partition method is safe: messages that were already routed stay in Only the order between the old and the new partition of the same aggregate is not guaranteed during the change. ::: +#### Circuit breaker + +A failure that hits every partition, like a database that is down, would put every partition into the error status +and let each of them use up its retries. To prevent that, the engine counts the failing partitions of a subscriber. +If a number of them fail in a row, without any success in between, the failure counts as systemic: +the main subscription goes into the error status, the partitions that failed are set back, +and no other partition is processed until the main subscription is retried. +After that, the partitions continue where they were, without having used up any retries. + +The default is 5 failures in a row, you can change it on the attribute. + +```php +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Message\Message; + +#[Projector('profile_1')] +final class ProfileProjector +{ + #[Partition(maxConsecutiveFailures: 20)] + public function partition(Message $message): string + { + return $message->header(TenantHeader::class)->tenant; + } + + // ... +} +``` +:::note +A partition that already used up its retries before the outage may still call its `#[OnFailed]` method. +::: + ## Subscription Engine The subscription engine manages individual subscribers and keeps the subscriptions running. diff --git a/src/Attribute/Partition.php b/src/Attribute/Partition.php index 2b5fc085c..8f58afdab 100644 --- a/src/Attribute/Partition.php +++ b/src/Attribute/Partition.php @@ -12,4 +12,15 @@ #[Attribute(Attribute::TARGET_METHOD)] final class Partition { + public function __construct( + /** + * After that many partitions of the subscriber failed in a row, without any success in between, the failure is + * treated as systemic (e.g. the database is down): the main subscription goes into the error status instead of + * every single partition, and the partitions wait until it is retried. + * + * @var positive-int + */ + public readonly int $maxConsecutiveFailures = 5, + ) { + } } diff --git a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php index f7089194c..bc2188c2e 100644 --- a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php @@ -169,11 +169,13 @@ public function metadata(string $subscriber): SubscriberMetadata $setupMethod = $method->getName(); } - if ($method->getAttributes(Partition::class)) { + $partitionAttributes = $method->getAttributes(Partition::class); + + if ($partitionAttributes !== []) { if ($partitionMethod !== null) { throw new DuplicatePartitionMethod( $subscriber, - $partitionMethod, + $partitionMethod->method, $method->getName(), ); } @@ -182,7 +184,10 @@ public function metadata(string $subscriber): SubscriberMetadata throw InvalidPartitionMethod::invalidSignature($subscriber, $method->getName()); } - $partitionMethod = $method->getName(); + $partitionMethod = new PartitionMetadata( + $method->getName(), + $partitionAttributes[0]->newInstance()->maxConsecutiveFailures, + ); } if ($method->getAttributes(Cleanup::class)) { diff --git a/src/Metadata/Subscriber/PartitionMetadata.php b/src/Metadata/Subscriber/PartitionMetadata.php new file mode 100644 index 000000000..23477cc79 --- /dev/null +++ b/src/Metadata/Subscriber/PartitionMetadata.php @@ -0,0 +1,15 @@ +eventDispatcher->addSubscriber( + new PartitionCircuitBreaker( + $this->subscriptionManager, + $this->subscriberRepository, + $this->retryStrategyRepository, + $this->logger, + ), + ); + $this->eventDispatcher->addSubscriber( new RemovePartitionsListener( $this->subscriptionManager, diff --git a/src/Subscription/Engine/Event/OnHandleMessage.php b/src/Subscription/Engine/Event/OnHandleMessage.php index bb618cd18..fb39058bc 100644 --- a/src/Subscription/Engine/Event/OnHandleMessage.php +++ b/src/Subscription/Engine/Event/OnHandleMessage.php @@ -12,6 +12,8 @@ final class OnHandleMessage public function __construct( public readonly Subscription $subscription, public readonly Message $message, + /** A listener can skip the message: no subscribe method is called and the position does not move. */ + public bool $skip = false, ) { } } diff --git a/src/Subscription/Engine/Listener/BatchSubscriber.php b/src/Subscription/Engine/Listener/BatchSubscriber.php index 767a94284..d3de19af6 100644 --- a/src/Subscription/Engine/Listener/BatchSubscriber.php +++ b/src/Subscription/Engine/Listener/BatchSubscriber.php @@ -39,6 +39,11 @@ public function onCommand(OnCommand $event): void public function onHandleMessage(OnHandleMessage $event): void { + // a skipped message must not open a batch, it may hold a transaction until the flush + if ($event->skip) { + return; + } + $subscriberId = $event->subscription->id(); if ($this->batchManager->has($subscriberId)) { diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreaker.php b/src/Subscription/Engine/Listener/PartitionCircuitBreaker.php new file mode 100644 index 000000000..04d92cc73 --- /dev/null +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreaker.php @@ -0,0 +1,262 @@ + subscriber id => failures in a row */ + private array $failuresInARow = []; + + /** @var array> subscriber id => failed partition ids */ + private array $failedPartitions = []; + + /** @var array subscriber id => the failure that tripped the breaker */ + private array $tripped = []; + + /** @var array subscriber ids with a successful partition */ + private array $succeeded = []; + + public function __construct( + private readonly SubscriptionManager $subscriptionManager, + private readonly SubscriberAccessorRepository $subscriberRepository, + private readonly RetryStrategyRepository $retryStrategyRepository, + private readonly LoggerInterface|null $logger = null, + ) { + } + + public function onCommand(OnCommand $event): void + { + $this->active = $event->command instanceof Run || $event->command instanceof Boot; + $this->failuresInARow = []; + $this->failedPartitions = []; + $this->tripped = []; + $this->succeeded = []; + } + + public function onHandleMessage(OnHandleMessage $event): void + { + if (!$event->subscription->isPartition() || !isset($this->tripped[$event->subscription->subscriberId()])) { + return; + } + + $event->skip = true; + } + + public function onHandleMessageSuccess(OnHandleMessageSuccess $event): void + { + if (!$event->subscription->isPartition()) { + return; + } + + $subscriberId = $event->subscription->subscriberId(); + + $this->failuresInARow[$subscriberId] = 0; + $this->succeeded[$subscriberId] = true; + } + + public function onHandleMessageError(OnHandleMessageError $event): void + { + if (!$this->active || !$event->subscription->isPartition()) { + return; + } + + $this->failure($event->subscription, $event->throwable); + } + + /** + * Failures outside of a message, like a failing batch flush or a whole group, only show up in the result. + * Then the main subscriptions of tripped subscribers go into error and their failed partitions are set back. + */ + public function onResult(OnResult $event): void + { + if (!$this->active) { + return; + } + + foreach ($event->result->errors as $error) { + // the id of a partition always contains the separator, the one of a partitioned subscriber never + if (!str_contains($error->subscriptionId, Subscription::PARTITION_SEPARATOR) || $this->isRecorded($error->subscriptionId)) { + continue; + } + + $partitions = $this->subscriptionManager->find(new SubscriptionCriteria(ids: [$error->subscriptionId], partitions: true)); + + foreach ($partitions as $partition) { + if ($partition->id() !== $error->subscriptionId) { + continue; + } + + $this->failure($partition, $error->throwable); + } + } + + foreach ($this->tripped as $subscriberId => $throwable) { + $this->stopSubscriber($subscriberId, $throwable); + } + + $this->resetRetries(); + + $this->active = false; + } + + private function failure(Subscription $partition, Throwable $throwable): void + { + $subscriberId = $partition->subscriberId(); + + $this->failedPartitions[$subscriberId][$partition->id()] = true; + + if (isset($this->tripped[$subscriberId])) { + return; + } + + $this->failuresInARow[$subscriberId] = ($this->failuresInARow[$subscriberId] ?? 0) + 1; + + $maxConsecutiveFailures = $this->subscriberRepository->get($subscriberId)?->metadata()->partition?->maxConsecutiveFailures; + + if ($maxConsecutiveFailures === null || $this->failuresInARow[$subscriberId] < $maxConsecutiveFailures) { + return; + } + + $this->tripped[$subscriberId] = $throwable; + + $this->logger?->warning(sprintf( + 'Subscription Engine: %d partitions of subscriber "%s" failed in a row, the subscriber is stopped: %s', + $this->failuresInARow[$subscriberId], + $subscriberId, + $throwable->getMessage(), + )); + } + + private function isRecorded(string $subscriptionId): bool + { + foreach ($this->failedPartitions as $partitionIds) { + if (isset($partitionIds[$subscriptionId])) { + return true; + } + } + + return false; + } + + private function stopSubscriber(string $subscriberId, Throwable $throwable): void + { + $this->subscriptionManager->forEachClaimed( + new SubscriptionCriteria(ids: [$subscriberId], status: [Status::Active, Status::Booting], partitions: false), + function (Subscription $main) use ($throwable): void { + if ($this->canRetry($main)) { + $main->error($throwable); + } else { + $main->failed($throwable); + } + + $this->subscriptionManager->update($main); + }, + ); + + $partitionIds = array_keys($this->failedPartitions[$subscriberId] ?? []); + + if ($partitionIds === []) { + return; + } + + // the partitions did not fail on their own, they continue where they were once the subscriber runs again + $this->subscriptionManager->forEachClaimed( + new SubscriptionCriteria(ids: $partitionIds, status: [Status::Error, Status::Failed], partitions: true), + function (Subscription $partition): void { + if ($partition->subscriptionError()?->previousStatus === Status::Booting) { + $partition->booting(); + } else { + $partition->active(); + } + + $this->subscriptionManager->update($partition); + }, + ); + } + + /** The main subscription only routes, its retries are reset once its partitions succeed again. */ + private function resetRetries(): void + { + $subscriberIds = []; + + foreach (array_keys($this->succeeded) as $subscriberId) { + if (isset($this->tripped[$subscriberId])) { + continue; + } + + $subscriberIds[] = $subscriberId; + } + + if ($subscriberIds === []) { + return; + } + + $this->subscriptionManager->forEachClaimed( + new SubscriptionCriteria(ids: $subscriberIds, status: [Status::Active, Status::Booting], partitions: false), + function (Subscription $main): void { + $main->resetRetry(); + $this->subscriptionManager->update($main); + }, + filter: static fn (Subscription $main): bool => $main->retryAttempt() > 0, + ); + } + + private function canRetry(Subscription $main): bool + { + $name = $this->subscriberRepository->get($main->subscriberId())?->metadata()->retryStrategy; + $retryStrategy = $name === null + ? $this->retryStrategyRepository->getDefaultRetryStrategy() + : $this->retryStrategyRepository->get($name); + + return !$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($main); + } + + /** @return array */ + public static function getSubscribedEvents(): array + { + return [ + OnCommand::class => 'onCommand', + // before the batch subscriber, a skipped message must not open a batch + OnHandleMessage::class => ['onHandleMessage', 32], + OnHandleMessageSuccess::class => 'onHandleMessageSuccess', + OnHandleMessageError::class => 'onHandleMessageError', + OnResult::class => 'onResult', + ]; + } +} diff --git a/src/Subscription/Engine/Listener/RetrySubscriber.php b/src/Subscription/Engine/Listener/RetrySubscriber.php index 4a2805167..faeab557d 100644 --- a/src/Subscription/Engine/Listener/RetrySubscriber.php +++ b/src/Subscription/Engine/Listener/RetrySubscriber.php @@ -106,6 +106,12 @@ public function onHandleMessageError(OnHandleMessageError $event): void public function onSuccessHandleMessage(OnHandleMessageSuccess $event): void { + // the main subscription of a partitioned subscriber only routes, that works even if the partitions fail. + // its retries are reset by the circuit breaker once its partitions succeed again. + if (!$event->subscription->isPartition() && $this->subscriberRepository->get($event->subscription->subscriberId())?->metadata()->partition !== null) { + return; + } + $event->subscription->resetRetry(); } diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index 50f35c3d9..ff5adefe2 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -89,7 +89,7 @@ public function process( return null; } - if (!$subscription->isPartition() && $subscriber->metadata()->partitionMethod !== null) { + if (!$subscription->isPartition() && $subscriber->metadata()->partition !== null) { if ($router === null) { throw new UnexpectedError(sprintf('The partitioned subscription "%s" needs a router.', $subscription->id())); } @@ -113,6 +113,16 @@ public function process( $this->eventDispatcher->dispatch($event); + if ($event->skip) { + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscription "%s" skipped the event "%s".', + $subscription->id(), + $message->event()::class, + )); + + return null; + } + $context = new ArgumentResolverContext($message, $subscription, $subscriber->metadata()); foreach ($subscribeMethods as $subscribeMethod) { diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 129c266da..b57a18a6a 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -15,6 +15,7 @@ use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use function array_keys; use function array_merge; use function array_values; use function count; @@ -57,6 +58,8 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $surviving[] = $subscription; } + $surviving = $this->withoutStoppedSubscribers($surviving); + if (count($surviving) > 1) { $separated = $this->processBatchesSeparately($surviving, $limit, $boot); @@ -180,6 +183,65 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals ); } + /** + * A main subscription in error stops its whole subscriber, e.g. after the circuit breaker detected a systemic + * failure. Its partitions wait without being touched until the main subscription is retried. + * + * @param list $subscriptions + * + * @return list + */ + private function withoutStoppedSubscribers(array $subscriptions): array + { + $subscriberIds = []; + + foreach ($subscriptions as $subscription) { + if (!$subscription->isPartition()) { + continue; + } + + $subscriberIds[$subscription->subscriberId()] = true; + } + + if ($subscriberIds === []) { + return $subscriptions; + } + + $stopped = []; + $mains = $this->subscriptionManager->find( + new SubscriptionCriteria(ids: array_keys($subscriberIds), partitions: false), + ); + + foreach ($mains as $main) { + if (!$main->isError() && !$main->isFailed()) { + continue; + } + + $stopped[$main->id()] = true; + } + + if ($stopped === []) { + return $subscriptions; + } + + $running = []; + + foreach ($subscriptions as $subscription) { + if ($subscription->isPartition() && isset($stopped[$subscription->subscriberId()])) { + $this->logger?->debug(sprintf( + 'Subscription Engine: Partition "%s" waits, its main subscription is in error.', + $subscription->id(), + )); + + continue; + } + + $running[] = $subscription; + } + + return $running; + } + /** * Batches of a group must not overlap. A batch often holds a resource like a database transaction from begin to * flush, and overlapping transactions on one connection are a stack: rolling back one batch would roll back the diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php index 1f619eed0..4bc490342 100644 --- a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php @@ -87,7 +87,7 @@ public function failedMethod(): Closure|null /** @return Closure(Message):mixed|null */ public function partitionMethod(): Closure|null { - $method = $this->metadata->partitionMethod; + $method = $this->metadata->partition?->method; if ($method === null) { return null; diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index a9015ad62..0aa2f6f02 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -41,6 +41,7 @@ use PHPUnit\Framework\TestCase; use function array_keys; +use function array_map; use function gc_collect_cycles; use function random_int; use function sort; @@ -573,4 +574,105 @@ public function testSubscriptionStoreWithoutInbox(): void self::assertStringContainsString('does not support partitions', $error->errorMessage); self::assertSame([], $this->partitions()); } + + /** Every handler fails, like when the database of the projection is down. */ + public function testCircuitBreakerStopsTheSubscriberOnASystemicFailure(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(20); + $projection->failingNames = array_map(static fn (int $i): string => 'name-' . $i, array_keys($ids)); + + $this->runUntilFinished($engine); + $this->runUntilFinished($engine); + + $main = $this->subscriptionStore->get(self::MAIN); + $error = $main->subscriptionError(); + self::assertSame(Status::Error, $main->status(), 'one visible error for the whole subscriber'); + self::assertNotNull($error); + self::assertStringContainsString('Forced failure', $error->errorMessage); + + $partitions = $this->partitions(); + self::assertCount(20, $partitions); + + foreach ($partitions as $partition) { + self::assertSame(Status::Active, $partition->status(), $partition->id()); + self::assertNull($partition->subscriptionError(), $partition->id()); + self::assertSame(0, $partition->retryAttempt(), $partition->id()); + self::assertCount(1, $this->subscriptionStore->inbox([$partition->id() => -1], 10)[$partition->id()] ?? [], 'the message is still in the inbox'); + } + + self::assertSame(5, $projection->handled, 'only partitions up to the threshold were tried'); + + // the database is back + $projection->failingNames = []; + $this->clock->sleep(10); + $this->runUntilFinished($engine); + + self::assertSame(Status::Active, $this->subscriptionStore->get(self::MAIN)->status()); + self::assertSame(0, $this->subscriptionStore->get(self::MAIN)->retryAttempt(), 'successful partitions reset the retries of the main subscription'); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i], $projection->processed[$id]); + } + } + + public function testSparseFailuresDoNotTripTheCircuitBreaker(): void + { + $projection = new PartitionedProfileProjection(); + $projection->failingNames = ['name-3', 'name-11']; + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(20); + $this->runUntilFinished($engine); + + self::assertSame(Status::Active, $this->subscriptionStore->get(self::MAIN)->status()); + + $failed = []; + + foreach ($this->partitions() as $partition) { + if (!$partition->isError()) { + continue; + } + + $failed[] = $partition->id(); + } + + self::assertCount(2, $failed); + self::assertCount(18, $projection->processed); + self::assertArrayNotHasKey($ids[3], $projection->processed); + } + + public function testPersistentOutageEventuallyFailsTheSubscriber(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(10); + $projection->failingNames = array_map(static fn (int $i): string => 'name-' . $i, array_keys($ids)); + + for ($i = 0; $i < 10; $i++) { + // new events keep coming in during the outage, the main subscription routes them successfully + $projection->failingNames[] = 'outage-' . $i; + $this->repository->save(Profile::create(ProfileId::generate(), 'outage-' . $i)); + + $engine->execute(new Run()); + $engine->execute(new Run()); + $this->clock->sleep(1000); + } + + self::assertSame(Status::Failed, $this->subscriptionStore->get(self::MAIN)->status(), 'the retries of the main subscription are used up'); + + foreach ($this->partitions() as $partition) { + self::assertSame(Status::Active, $partition->status(), 'the partitions did not use up their retries'); + self::assertSame(0, $partition->retryAttempt()); + } + } } diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index 50a912f0e..c06011a1f 100644 --- a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -40,6 +40,7 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\IncompleteBatchMethods; use Patchlevel\EventSourcing\Metadata\Subscriber\InvalidPartitionMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\MixedTeardownAndCleanupMethods; +use Patchlevel\EventSourcing\Metadata\Subscriber\PartitionMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; @@ -820,7 +821,7 @@ public function partition(Message $message): string $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadata = $metadataFactory->metadata($subscriber::class); - self::assertSame('partition', $metadata->partitionMethod); + self::assertEquals(new PartitionMetadata('partition', 5), $metadata->partition); } public function testNoPartitionMethod(): void @@ -832,7 +833,7 @@ class { $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadata = $metadataFactory->metadata($subscriber::class); - self::assertNull($metadata->partitionMethod); + self::assertNull($metadata->partition); } public function testDuplicatePartitionMethodException(): void @@ -908,4 +909,20 @@ public function partition(Message $message): string $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadataFactory->metadata($subscriber::class); } + + public function testPartitionMethodWithMaxConsecutiveFailures(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + #[Partition(maxConsecutiveFailures: 20)] + public function partition(Message $message): string + { + return 'key'; + } + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + + self::assertEquals(new PartitionMetadata('partition', 20), $metadataFactory->metadata($subscriber::class)->partition); + } } diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php new file mode 100644 index 000000000..3747ecd4b --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php @@ -0,0 +1,196 @@ + */ + private array $partitions; + + public function testTripsAfterTheThresholdAndSkipsTheOtherPartitions(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + foreach ([0, 1] as $i) { + $breaker->onHandleMessageError($this->error($this->partitions[$i])); + } + + self::assertFalse($this->handle($breaker, $this->partitions[3])->skip, 'not tripped yet'); + + $breaker->onHandleMessageError($this->error($this->partitions[2])); + + self::assertTrue($this->handle($breaker, $this->partitions[3])->skip); + self::assertFalse($this->handle($breaker, new Subscription('other#a', partitionKey: 'a'))->skip, 'other subscribers go on'); + } + + public function testSuccessResetsTheFailuresInARow(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $breaker->onHandleMessageError($this->error($this->partitions[0])); + $breaker->onHandleMessageError($this->error($this->partitions[1])); + $breaker->onHandleMessageSuccess(new OnHandleMessageSuccess($this->partitions[2], $this->message(), 1)); + $breaker->onHandleMessageError($this->error($this->partitions[3])); + $breaker->onHandleMessageError($this->error($this->partitions[4])); + + self::assertFalse($this->handle($breaker, $this->partitions[5])->skip); + } + + public function testStopsTheMainSubscriptionAndSetsBackThePartitions(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + foreach ([0, 1, 2] as $i) { + $this->partitions[$i]->error(new RuntimeException('database down')); + $this->store->update($this->partitions[$i]); + $breaker->onHandleMessageError($this->error($this->partitions[$i])); + } + + $breaker->onResult(new OnResult(new Run(), new Result())); + + $main = $this->store->get('profile'); + self::assertSame(Status::Failed, $main->status(), 'without retries left the main subscription fails right away'); + self::assertSame('database down', $main->subscriptionError()?->errorMessage); + + foreach ($this->partitions as $partition) { + self::assertSame(Status::Active, $this->store->get($partition->id())->status(), $partition->id()); + self::assertNull($this->store->get($partition->id())->subscriptionError()); + } + } + + public function testFailedGroupInTheResultTripsTheBreaker(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $errors = []; + + foreach ([0, 1, 2, 3] as $i) { + $this->partitions[$i]->error(new RuntimeException('connection lost')); + $this->store->update($this->partitions[$i]); + $errors[] = new Error($this->partitions[$i]->id(), 'connection lost', new RuntimeException('connection lost')); + } + + $breaker->onResult(new OnResult(new Run(), new Result($errors))); + + self::assertNotSame(Status::Active, $this->store->get('profile')->status()); + + foreach ($this->partitions as $partition) { + self::assertSame(Status::Active, $this->store->get($partition->id())->status(), $partition->id()); + } + } + + public function testOnlyRunAndBoot(): void + { + $breaker = $this->breaker(1); + $breaker->onCommand(new OnCommand(new Setup())); + + $breaker->onHandleMessageError($this->error($this->partitions[0])); + $breaker->onResult(new OnResult(new Setup(), new Result())); + + self::assertSame(Status::Active, $this->store->get('profile')->status()); + } + + /** @param positive-int $maxConsecutiveFailures */ + private function breaker(int $maxConsecutiveFailures): PartitionCircuitBreaker + { + $subscriber = $maxConsecutiveFailures === 1 + ? new #[Subscriber('profile', RunMode::FromBeginning)] + class { + #[Partition(maxConsecutiveFailures: 1)] + public function partition(Message $message): string + { + return 'key'; + } + + #[Subscribe(ProfileVisited::class)] + public function on(ProfileVisited $event): void + { + } + } + : new #[Subscriber('profile', RunMode::FromBeginning)] + class { + #[Partition(maxConsecutiveFailures: 3)] + public function partition(Message $message): string + { + return 'key'; + } + + #[Subscribe(ProfileVisited::class)] + public function on(ProfileVisited $event): void + { + } + }; + + $main = new Subscription('profile', status: Status::Active, position: 10); + $this->partitions = array_map( + static fn (int $i): Subscription => Subscription::partition($main, (string)$i, 0), + range(0, 5), + ); + + $this->store = new InMemorySubscriptionStore([$main, ...$this->partitions]); + + return new PartitionCircuitBreaker( + new SubscriptionManager($this->store), + new MetadataSubscriberAccessorRepository([$subscriber]), + RetryStrategyRepository::withDefault(new NoRetryStrategy()), + ); + } + + private function error(Subscription $partition): OnHandleMessageError + { + return new OnHandleMessageError($partition, new RuntimeException('database down'), $this->message(), 1); + } + + private function handle(PartitionCircuitBreaker $breaker, Subscription $partition): OnHandleMessage + { + $event = new OnHandleMessage($partition, $this->message()); + $breaker->onHandleMessage($event); + + return $event; + } + + private function message(): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString('1'))); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php index 4749cde63..aa342a858 100644 --- a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -5,11 +5,14 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine\Listener; use Generator; +use Patchlevel\EventSourcing\Attribute\Partition; 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; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageSuccess; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; @@ -19,6 +22,8 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; use Patchlevel\EventSourcing\Tests\Unit\Subscription\DummySubscriptionStore; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; @@ -172,4 +177,28 @@ class { self::assertSame(Status::Active, $store->get('test')->status()); self::assertSame(Status::Error, $store->get('test#a')->status()); } + + public function testRoutingDoesNotResetTheRetriesOfThePartitionedMain(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + #[Partition] + public function partition(Message $message): string + { + return 'key'; + } + }; + + $main = new Subscription('test', status: Status::Active, retryAttempt: 3); + $partition = new Subscription('test#key', status: Status::Active, retryAttempt: 2, partitionKey: 'key'); + + $listener = $this->createListener(new DummySubscriptionStore([$main, $partition]), [$subscriber], $this->createMock(RetryStrategy::class)); + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $listener->onSuccessHandleMessage(new OnHandleMessageSuccess($main, $message, 1)); + $listener->onSuccessHandleMessage(new OnHandleMessageSuccess($partition, $message, 1)); + + self::assertSame(3, $main->retryAttempt(), 'routing works even if the partitions fail'); + self::assertSame(0, $partition->retryAttempt()); + } } diff --git a/tests/Unit/Subscription/Engine/MessageProcessorTest.php b/tests/Unit/Subscription/Engine/MessageProcessorTest.php index 9f2b60061..d3a54ba83 100644 --- a/tests/Unit/Subscription/Engine/MessageProcessorTest.php +++ b/tests/Unit/Subscription/Engine/MessageProcessorTest.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; @@ -141,4 +142,38 @@ public function onProfileVisited(ProfileVisited $event, int $unresolved): void self::assertNotNull($error); self::assertInstanceOf(NoSuitableResolver::class, $error->throwable); } + + public function testListenerCanSkipAMessage(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $called = false; + + #[Subscribe(ProfileVisited::class)] + public function onProfileVisited(ProfileVisited $event): void + { + $this->called = true; + } + }; + + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(OnHandleMessage::class, static function (OnHandleMessage $event): void { + $event->skip = true; + }); + + $processor = new MessageProcessor( + new MetadataSubscriberAccessorRepository([$subscriber]), + $eventDispatcher, + [], + new NullLogger(), + ); + + $subscription = new Subscription('test', status: Status::Active, position: 0); + + $error = $processor->process(1, Message::create(new ProfileVisited(ProfileId::fromString('1'))), $subscription); + + self::assertNull($error); + self::assertFalse($subscriber->called); + self::assertSame(0, $subscription->position(), 'a skipped message does not move the position'); + } } From ee14631d29cd8464e13a08017a3d1c1e826b03d8 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 10:15:31 +0200 Subject: [PATCH 13/57] Add #[PartitionKey] to resolve the key of the processing partition A subscribe method can ask for the partition key with a parameter marked #[PartitionKey], e.g. to write into the database of a tenant, without computing the key from the message again. Without partitions the value is null. Only the key is handed out, the subscription itself stays inside the engine so a handler can not change its state. --- docs/subscription.md | 28 ++++++++++ src/Attribute/PartitionKey.php | 16 ++++++ src/Subscription/Engine/MessageProcessor.php | 2 + .../PartitionKeyArgumentResolver.php | 22 ++++++++ .../PartitionedSubscriptionTest.php | 1 + .../PartitionedProfileProjection.php | 8 ++- .../Engine/MessageProcessorTest.php | 37 +++++++++++++ .../PartitionKeyArgumentResolverTest.php | 55 +++++++++++++++++++ 8 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 src/Attribute/PartitionKey.php create mode 100644 src/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolver.php create mode 100644 tests/Unit/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolverTest.php diff --git a/docs/subscription.md b/docs/subscription.md index 55d53c540..a6ae8da8b 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -313,6 +313,34 @@ final class DoStuffSubscriber } } ``` +##### Partition Key Resolver + +The partition key resolver resolves the key of the [partition](#partitioning) that processes the message. +It looks for a parameter with the `#[PartitionKey]` attribute, without partitions the value is `null`. + +```php +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\PartitionKey; +use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Message\Message; + +#[Projector('profile_1')] +final class ProfileProjector +{ + #[Partition] + public function partition(Message $message): string + { + return $message->header(TenantHeader::class)->tenant; + } + + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event, #[PartitionKey] string $tenant): void + { + // write into the database of the tenant + } +} +``` ##### Event Emitter Resolver A subscriber can not only build a projection or trigger a side effect, it can also emit new events diff --git a/src/Attribute/PartitionKey.php b/src/Attribute/PartitionKey.php new file mode 100644 index 000000000..c28194bdb --- /dev/null +++ b/src/Attribute/PartitionKey.php @@ -0,0 +1,16 @@ +subscription->partitionKey(); + } + + public function support(ArgumentMetadata $argument, string $eventClass): bool + { + return $argument->hasAttribute(PartitionKey::class); + } +} diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 0aa2f6f02..f2588a1e6 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -102,6 +102,7 @@ public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void foreach ($ids as $i => $id) { self::assertSame(['name-' . $i], $projection->processed[$id]); + self::assertSame('profile-' . $id, $projection->partitionKeys[$id], 'the handler gets the key of its partition'); } foreach ($this->partitions() as $partition) { diff --git a/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php index 482db6a14..ca7c0e429 100644 --- a/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php @@ -6,6 +6,7 @@ use Closure; use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\PartitionKey; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; @@ -26,6 +27,9 @@ final class PartitionedProfileProjection public int $handled = 0; + /** @var array profile id => partition key that processed its creation */ + public array $partitionKeys = []; + /** @var list names that throw while processing */ public array $failingNames = []; @@ -44,8 +48,10 @@ public function partition(Message $message): string } #[Subscribe(ProfileCreated::class)] - public function handleProfileCreated(ProfileCreated $event): void + public function handleProfileCreated(ProfileCreated $event, #[PartitionKey] + string $partitionKey,): void { + $this->partitionKeys[$event->profileId->toString()] = $partitionKey; $this->record($event->profileId->toString(), $event->name); } diff --git a/tests/Unit/Subscription/Engine/MessageProcessorTest.php b/tests/Unit/Subscription/Engine/MessageProcessorTest.php index d3a54ba83..e4befc57d 100644 --- a/tests/Unit/Subscription/Engine/MessageProcessorTest.php +++ b/tests/Unit/Subscription/Engine/MessageProcessorTest.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\PartitionKey; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; @@ -176,4 +178,39 @@ public function onProfileVisited(ProfileVisited $event): void self::assertFalse($subscriber->called); self::assertSame(0, $subscription->position(), 'a skipped message does not move the position'); } + + public function testPartitionGetsItsPartitionKey(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public string|null $partitionKey = null; + + #[Partition] + public function partition(Message $message): string + { + return 'tenant-a'; + } + + #[Subscribe(ProfileVisited::class)] + public function onProfileVisited(ProfileVisited $event, #[PartitionKey] + string $partitionKey,): void + { + $this->partitionKey = $partitionKey; + } + }; + + $processor = new MessageProcessor( + new MetadataSubscriberAccessorRepository([$subscriber]), + new EventDispatcher(), + [], + new NullLogger(), + ); + + $partition = Subscription::partition(new Subscription('test', status: Status::Active), 'tenant-a', 0); + + $error = $processor->process(1, Message::create(new ProfileVisited(ProfileId::fromString('1'))), $partition); + + self::assertNull($error); + self::assertSame('tenant-a', $subscriber->partitionKey); + } } diff --git a/tests/Unit/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolverTest.php b/tests/Unit/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolverTest.php new file mode 100644 index 000000000..01dc63d8c --- /dev/null +++ b/tests/Unit/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolverTest.php @@ -0,0 +1,55 @@ +support(new ArgumentMetadata('tenant', Type::string(), [new PartitionKey()]), 'qux')); + self::assertFalse($resolver->support(new ArgumentMetadata('tenant', Type::string()), 'qux')); + } + + public function testResolvePartitionKey(): void + { + $resolver = new PartitionKeyArgumentResolver(); + $partition = Subscription::partition(new Subscription('profile'), 'tenant-a', 0); + + self::assertSame( + 'tenant-a', + $resolver->resolve( + new ArgumentMetadata('tenant', Type::string(), [new PartitionKey()]), + new ArgumentResolverContext(new Message(new stdClass()), $partition, new SubscriberMetadata('profile')), + ), + ); + } + + public function testResolveWithoutPartition(): void + { + $resolver = new PartitionKeyArgumentResolver(); + + self::assertNull( + $resolver->resolve( + new ArgumentMetadata('tenant', Type::nullable(Type::string()), [new PartitionKey()]), + new ArgumentResolverContext(new Message(new stdClass()), new Subscription('profile'), new SubscriberMetadata('profile')), + ), + ); + } +} From 829dbe53c2e12714b5120f58d815ef1ae2aba032 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 10:55:59 +0200 Subject: [PATCH 14/57] Load the inbox messages of a group in chunks A group of partitions loaded the indices of all its inboxes with one IN list. With 100 partitions and a limit of 1000 that are up to 100k parameters, PostgreSQL allows 65535, so the whole group went into error and the circuit breaker stopped the subscriber. The indices are now loaded in chunks of 1000. --- .../Engine/SubscriptionRunner.php | 41 +++++++++++++- .../Engine/SubscriptionRunnerTest.php | 55 ++++++++++++++++++- 2 files changed, 94 insertions(+), 2 deletions(-) diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index b57a18a6a..4285490aa 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -4,6 +4,8 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; +use Generator; +use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Store\UnsupportedCriterion; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptionProcessed; @@ -15,8 +17,10 @@ use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; +use function array_chunk; use function array_keys; use function array_merge; +use function array_slice; use function array_values; use function count; use function ksort; @@ -30,6 +34,9 @@ final class SubscriptionRunner /** How many messages of its inbox a partition processes at most in one run without a limit. */ private const INBOX_LIMIT = 1000; + /** How many indices are loaded with one query, far below the parameter limits of the databases. */ + private const INDEX_CHUNK = 1000; + public function __construct( private readonly MessageLoader $messageLoader, private readonly SubscriptionManager $subscriptionManager, @@ -383,11 +390,43 @@ private function loadIndices(array $indices): Stream|null $this->logger?->debug(sprintf('Subscription Engine: %d messages of the inbox are processed.', count($indices))); + // the inboxes of a whole group can hold far more indices than a query may have parameters + $chunks = array_chunk($indices, self::INDEX_CHUNK); + try { - return $this->messageLoader->loadIndices($indices); + $first = $this->messageLoader->loadIndices($chunks[0]); } catch (UnsupportedCriterion) { return null; } + + if (count($chunks) === 1) { + return $first; + } + + return new Stream($this->loadChunks($this->messageLoader, $first, array_slice($chunks, 1))); + } + + /** + * Loads the next chunk only when the previous one is consumed, so the messages stay in ascending order. + * + * @param list> $chunks + * + * @return Generator + */ + private function loadChunks(IndexMessageLoader $messageLoader, Stream $stream, array $chunks): Generator + { + try { + yield from $stream; + + foreach ($chunks as $chunk) { + $stream->close(); + $stream = $messageLoader->loadIndices($chunk); + + yield from $stream; + } + } finally { + $stream->close(); + } } /** diff --git a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php index d8da7ef03..0196539ec 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; +use Patchlevel\EventSourcing\Subscription\Engine\IndexMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; @@ -14,6 +15,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionRunner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; @@ -24,6 +26,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; +use function range; + #[CoversClass(SubscriptionRunner::class)] final class SubscriptionRunnerTest extends TestCase { @@ -165,15 +169,64 @@ public function testProcessBootActivatesSubscription(): void self::assertSame(Status::Active, $subscription->status()); } + public function testPartitionsLoadTheirInboxesInChunks(): void + { + $main = new Subscription('foo', status: Status::Active, position: 3000); + $partitions = [ + Subscription::partition($main, 'a', 0), + Subscription::partition($main, 'b', 0), + Subscription::partition($main, 'c', 0), + ]; + + $store = new InMemorySubscriptionStore([$main, ...$partitions]); + + foreach ($partitions as $offset => $partition) { + $store->addToInbox([$partition->id() => range($offset + 1, 3000, 3)]); + } + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + /** @var list> $loadedChunks */ + $loadedChunks = []; + + $loader = $this->createMock(IndexMessageLoader::class); + $loader + ->expects($this->exactly(3)) + ->method('loadIndices') + ->willReturnCallback(static function (array $indices) use ($message, &$loadedChunks): Stream { + $loadedChunks[] = $indices; + $messages = []; + + foreach ($indices as $index) { + self::assertIsInt($index); + $messages[$index] = $message; + } + + return new Stream($messages); + }); + + $runner = $this->runner($loader, $this->subscriberRepository(), $store); + + $result = $runner->process($partitions, null); + + self::assertSame([], $result->errors); + self::assertSame(3000, $result->processedMessages); + self::assertSame([range(1, 1000), range(1001, 2000), range(2001, 3000)], $loadedChunks); + self::assertSame(2998, $partitions[0]->position()); + self::assertSame(2999, $partitions[1]->position()); + self::assertSame(3000, $partitions[2]->position()); + } + private function runner( MessageLoader $loader, SubscriberAccessorRepository $subscriberRepository, + SubscriptionStore|null $store = null, ): SubscriptionRunner { $eventDispatcher = new EventDispatcher(); return new SubscriptionRunner( $loader, - new SubscriptionManager($this->createMock(SubscriptionStore::class)), + new SubscriptionManager($store ?? $this->createMock(SubscriptionStore::class)), $subscriberRepository, new MessageProcessor($subscriberRepository, $eventDispatcher), $eventDispatcher, From 690923345b1ea856c1edcc11cd93d81c4e15ab82 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 10:58:05 +0200 Subject: [PATCH 15/57] Move a partition past inbox messages that no longer exist If a routed message was removed from the store before its partition processed it, the partition never loaded it, its position stayed in front of it and it was claimed on every run. When a partition processed every loaded message, it now moves to the end of its inbox and logs the skipped range. --- .../Engine/SubscriptionRunner.php | 48 +++++++++++++++---- .../PartitionedSubscriptionTest.php | 39 ++++++++++++++- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 4285490aa..7573f20ee 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -91,6 +91,8 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $stream = null; $messageCounter = 0; $lastIndex = null; + /** @var array $loaded */ + $loaded = []; $limitReached = false; if (count($working) > 0) { @@ -119,6 +121,7 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $messageCounter++; $lastIndex = $index; + $loaded[$index] = true; foreach ($working as $member) { if ($member->position() >= $index) { @@ -167,14 +170,17 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $this->eventDispatcher->dispatch($event); $errors = array_merge($errors, $event->errors); } + } - foreach ($working as $member) { - if ($member->isPartition()) { - $this->finishInbox($member, $inboxes[$member->id()]); - } - - $this->subscriptionManager->update($member); + foreach ($working as $member) { + if ($member->isPartition()) { + // also without any loaded message, then every message of the inbox was removed from the store + $this->finishInbox($member, $inboxes[$member->id()], $loaded); + } elseif ($lastIndex === null) { + continue; } + + $this->subscriptionManager->update($member); } $inboxFull = $this->hasFullInbox($members, $inboxes, $inboxLimit); @@ -462,15 +468,39 @@ private function inboxIndices(SubscriptionCollection $working, array $inboxes): /** * A partition never moves past its inbox, otherwise it would skip messages the main subscription routes later. * The store clears the inbox up to the position when the partition is updated, a failed one included. + * + * Messages of the inbox that were not loaded no longer exist in the store, e.g. because their stream was removed. + * If the partition processed every loaded message, it moves past them, otherwise it would wait for them forever. + * + * @param array $loaded indices of the loaded messages */ - private function finishInbox(Subscription $partition, PartitionInbox $inbox): void + private function finishInbox(Subscription $partition, PartitionInbox $inbox, array $loaded): void { - $position = $partition->position(); + $position = $partition->position() ?? -1; + + if ($position >= $inbox->last()) { + $partition->changePosition($inbox->last()); + + return; + } - if ($position === null || $position <= $inbox->last()) { + if ($partition->isError() || $partition->isFailed()) { return; } + foreach ($inbox->indices() as $index) { + // a loaded message that was not processed, e.g. skipped, has to stay in the inbox + if ($index > $position && isset($loaded[$index])) { + return; + } + } + + $this->logger?->warning(sprintf( + 'Subscription Engine: Partition "%s" skips messages of its inbox up to %d, they no longer exist.', + $partition->id(), + $inbox->last(), + )); + $partition->changePosition($inbox->last()); } diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index f2588a1e6..e6b245165 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -15,6 +15,8 @@ use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +use Patchlevel\EventSourcing\Store\Criteria\Criteria; +use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; @@ -54,6 +56,7 @@ final class PartitionedSubscriptionTest extends TestCase private Connection $connection; private FrozenClock $clock; private DoctrineSubscriptionStore $subscriptionStore; + private StreamDoctrineDbalStore $store; /** @var Repository */ private Repository $repository; @@ -313,10 +316,44 @@ public function testBatchSubscriberWithPartitions(): void } } + /** A routed message whose stream was removed before the partition processed it must not block the partition. */ + public function testRemovedMessagesDoNotBlockThePartition(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + [$removedId, $keptId] = $this->createProfiles(2); + $this->runUntilFinished($engine); + + $this->changeName(ProfileId::fromString($removedId), 'removed'); + $this->changeName(ProfileId::fromString($keptId), 'kept'); + + $engine->execute(new Run()); // the main subscription routes both messages + + $this->store->remove(new Criteria(new StreamCriterion('profile-' . $removedId))); + + $this->runUntilFinished($engine); + + self::assertSame(['name-1', 'kept'], $projection->processed[$keptId]); + self::assertSame(['name-0'], $projection->processed[$removedId]); + + $partitionId = Subscription::partitionId(self::MAIN, 'profile-' . $removedId); + + self::assertSame(Status::Active, $this->subscriptionStore->get($partitionId)->status()); + self::assertSame([], $this->subscriptionStore->inbox([$partitionId => -1], 10), 'the removed message left the inbox'); + self::assertSame( + [], + $this->subscriptionStore->find(new SubscriptionCriteria(status: [Status::Active], pendingPartitionsOnly: true, partitions: true)), + 'no partition waits for a message that does not exist anymore', + ); + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { - $store = new StreamDoctrineDbalStore( + $store = $this->store = new StreamDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), ); From 255def08be8d59f8b35ef4a55676e6ab9903a507 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:03:27 +0200 Subject: [PATCH 16/57] Keep the boot of partitioned subscribers linear Every boot run claimed all booting partitions, also the ones without work, and looked up the main subscription once per partition. With one partition per aggregate the boot grew quadratically (4000 aggregates: 27k queries, 25s). Boot now only claims partitions with messages in their inbox, like run does. Once a main subscription finished booting, its remaining booting partitions are processed one more time to activate them. The runner loads the main subscriptions of a group with one query. The same boot now needs 1.2k queries and 4s. --- .../Engine/Handler/BootHandler.php | 96 +++++++++++++++---- .../Engine/SubscriptionRunner.php | 78 ++++++++------- 2 files changed, 120 insertions(+), 54 deletions(-) diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 56d673ea7..7cb31534d 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -33,34 +33,94 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { + $supportsPartitions = $this->subscriptionManager->supportsPartitions(); + $claimGroups = new ClaimGroups( + $command->subscriptionsPerClaim, + $command->partitionsPerClaim, + // the group reads the stream from its lowest position, a larger gap would use up the limit of the others + $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, + ); + /** @var list $results */ $results = $this->subscriptionManager->forEachClaimedGroup( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, status: [Status::Booting], + // partitions without messages in their inbox have nothing to do while their main subscription boots + pendingPartitionsOnly: $supportsPartitions, ), - new ClaimGroups( - $command->subscriptionsPerClaim, - $command->partitionsPerClaim, - // the group reads the stream from its lowest position, a larger gap would use up the limit of the others - $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, + $claimGroups, + fn (array $subscriptions): ProcessedResult => $this->process($subscriptions, $command), + $this->onError(...), + ); + + if ($supportsPartitions) { + $results = [...$results, ...$this->finishPartitions($command, $claimGroups)]; + } + + return ProcessedResult::merge($results); + } + + /** + * Once the main subscription finished booting, its partitions that are still booting are processed one last time, + * so that they are set to active, or finished for run mode once, even if their inbox is already empty. + * + * @return list + */ + private function finishPartitions(Boot $command, ClaimGroups $claimGroups): array + { + $subscriberIds = []; + + $mains = $this->subscriptionManager->find(new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Active, Status::Finished], + partitions: false, + )); + + foreach ($mains as $main) { + $subscriberIds[] = $main->id(); + } + + if ($subscriberIds === []) { + return []; + } + + $criteria = new SubscriptionCriteria(ids: $subscriberIds, status: [Status::Booting], partitions: true); + + if ($this->subscriptionManager->find($criteria) === []) { + return []; + } + + return [ + ...$this->subscriptionManager->forEachClaimedGroup( + $criteria, + $claimGroups, + fn (array $subscriptions): ProcessedResult => $this->process($subscriptions, $command), + $this->onError(...), ), - fn (array $subscriptions): ProcessedResult => $this->runner->process( + // a partition held by another worker is not claimed, the next boot run checks again + new ProcessedResult(0, false), + ]; + } + + /** @param non-empty-list $subscriptions */ + private function process(array $subscriptions, Boot $command): ProcessedResult + { + return $this->runner->process($subscriptions, $command->limit, boot: true); + } + + /** @param non-empty-list $subscriptions */ + private function onError(array $subscriptions, Throwable $e): ProcessedResult + { + return new ProcessedResult( + 0, + false, + array_map( + static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), $subscriptions, - $command->limit, - boot: true, - ), - static fn (array $subscriptions, Throwable $e): ProcessedResult => new ProcessedResult( - 0, - false, - array_map( - static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), - $subscriptions, - ), ), ); - - return ProcessedResult::merge($results); } } diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 7573f20ee..05e467ed6 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -65,7 +65,9 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $surviving[] = $subscription; } - $surviving = $this->withoutStoppedSubscribers($surviving); + // the main subscriptions of the partitions, loaded once for the whole group + $mains = $this->mains($surviving); + $surviving = $this->withoutStoppedSubscribers($surviving, $mains); if (count($surviving) > 1) { $separated = $this->processBatchesSeparately($surviving, $limit, $boot); @@ -186,7 +188,7 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $inboxFull = $this->hasFullInbox($members, $inboxes, $inboxLimit); if (!$limitReached && !$inboxFull) { - $this->finishMembers($members, $boot); + $this->finishMembers($members, $mains, $boot); } return new ProcessedResult( @@ -197,14 +199,11 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals } /** - * A main subscription in error stops its whole subscriber, e.g. after the circuit breaker detected a systemic - * failure. Its partitions wait without being touched until the main subscription is retried. - * * @param list $subscriptions * - * @return list + * @return array subscriber id => main subscription */ - private function withoutStoppedSubscribers(array $subscriptions): array + private function mains(array $subscriptions): array { $subscriberIds = []; @@ -217,30 +216,39 @@ private function withoutStoppedSubscribers(array $subscriptions): array } if ($subscriberIds === []) { - return $subscriptions; + return []; } - $stopped = []; - $mains = $this->subscriptionManager->find( - new SubscriptionCriteria(ids: array_keys($subscriberIds), partitions: false), - ); + $mains = []; - foreach ($mains as $main) { - if (!$main->isError() && !$main->isFailed()) { - continue; - } - - $stopped[$main->id()] = true; + foreach ( + $this->subscriptionManager->find( + new SubscriptionCriteria(ids: array_keys($subscriberIds), partitions: false), + ) as $main + ) { + $mains[$main->id()] = $main; } - if ($stopped === []) { - return $subscriptions; - } + return $mains; + } + /** + * A main subscription in error stops its whole subscriber, e.g. after the circuit breaker detected a systemic + * failure. Its partitions wait without being touched until the main subscription is retried. + * + * @param list $subscriptions + * @param array $mains + * + * @return list + */ + private function withoutStoppedSubscribers(array $subscriptions, array $mains): array + { $running = []; foreach ($subscriptions as $subscription) { - if ($subscription->isPartition() && isset($stopped[$subscription->subscriberId()])) { + $main = $subscription->isPartition() ? $mains[$subscription->subscriberId()] ?? null : null; + + if ($main !== null && ($main->isError() || $main->isFailed())) { $this->logger?->debug(sprintf( 'Subscription Engine: Partition "%s" waits, its main subscription is in error.', $subscription->id(), @@ -516,7 +524,8 @@ private function hasFullInbox(SubscriptionCollection $members, array $inboxes, i return false; } - private function finishMembers(SubscriptionCollection $members, bool $boot): void + /** @param array $mains */ + private function finishMembers(SubscriptionCollection $members, array $mains, bool $boot): void { foreach ($members as $member) { if ($boot) { @@ -525,11 +534,11 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi } // the main subscription may still route messages into the inbox of a booting partition - if ($member->isPartition() && $this->main($member)?->isBooting()) { + if ($member->isPartition() && ($mains[$member->subscriberId()] ?? null)?->isBooting()) { continue; } - if ($member->runMode() === RunMode::Once && $this->isRoutingFinished($member)) { + if ($member->runMode() === RunMode::Once && $this->isRoutingFinished($member, $mains)) { $member->finished(); $this->logger?->info(sprintf( @@ -554,7 +563,7 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi continue; } - if (!$this->isRoutingFinished($member)) { + if (!$this->isRoutingFinished($member, $mains)) { continue; } @@ -568,20 +577,17 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi } } - /** A partition is only done when its main subscription has routed all messages. */ - private function isRoutingFinished(Subscription $subscription): bool + /** + * A partition is only done when its main subscription has routed all messages. + * + * @param array $mains + */ + private function isRoutingFinished(Subscription $subscription, array $mains): bool { if (!$subscription->isPartition()) { return true; } - return $this->main($subscription)?->isFinished() ?? true; - } - - private function main(Subscription $partition): Subscription|null - { - return $this->subscriptionManager->find( - new SubscriptionCriteria(ids: [$partition->subscriberId()], partitions: false), - )[0] ?? null; + return ($mains[$subscription->subscriberId()] ?? null)?->isFinished() ?? true; } } From e929d78c458f253a4338a220eee2944d8469beff Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:20:58 +0200 Subject: [PATCH 17/57] Look up the partitions of a run together The router looked up every new key on its own and after 50 lookups loaded all partitions of the subscriber. The cache only lives for one run, so with a small message limit and many keys every run loaded every partition. The router now remembers the partitions it does not know yet and creates the missing ones at the end of the run, looked up with one query per 500 ids. This also happens when the run fails, so routed messages always have their partition. Catching up 8000 aggregates with a limit of 100 went from 22s to 11s on PostgreSQL. --- src/Subscription/Engine/PartitionRouter.php | 94 +++++++++---------- .../Engine/SubscriptionRunner.php | 2 + .../PartitionedSubscriptionTest.php | 59 ++++++++++++ 3 files changed, 105 insertions(+), 50 deletions(-) diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index d11e85b8e..9082c38b8 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -10,6 +10,8 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; +use function array_chunk; +use function array_keys; use function is_string; use function sprintf; use function strlen; @@ -21,6 +23,7 @@ * non-deterministic key method can not make a message disappear. * * A router lives for one run of the claimed main subscription, so the known partitions never go stale. + * The partitions of new keys are created by createPartitions() at the end of the run. * * @internal */ @@ -29,17 +32,14 @@ final class PartitionRouter /** Maximum length of a subscription id in bytes, the id of a partition has to fit into it. */ public const MAX_ID_LENGTH = 255; - /** After that many lookups of single partitions, all partitions of the subscriber are loaded at once. */ - private const SINGLE_LOOKUPS = 50; + /** How many partition ids are looked up with one query. */ + private const LOOKUP_CHUNK = 500; /** @var array partition ids known to exist */ private array $knownPartitions = []; - /** @var array main subscription id => single lookups so far */ - private array $lookups = []; - - /** @var array main subscriptions whose partitions are all loaded */ - private array $loaded = []; + /** @var array partition id => main subscription, key and first routed index */ + private array $unknownPartitions = []; private bool $routed = false; @@ -58,20 +58,48 @@ public function route(int $index, Message $message, Subscription $main, Metadata // fails first if the store has no inbox, so no partition is created that could never be processed $this->subscriptionManager->addToInbox($partitionId, $index); - if (!$this->exists($main, $partitionId)) { - $this->subscriptionManager->add(Subscription::partition($main, $partitionKey, $index - 1)); - $this->knownPartitions[$partitionId] = true; - - $this->logger?->info(sprintf( - 'Subscription Engine: Partition "%s" of subscriber "%s" created.', - $partitionKey, - $main->id(), - )); + if (!isset($this->knownPartitions[$partitionId]) && !isset($this->unknownPartitions[$partitionId])) { + $this->unknownPartitions[$partitionId] = [$main, $partitionKey, $index]; } $this->routed = true; } + /** + * Creates the partitions of the routed messages that do not exist yet. The partitions are looked up together + * with one query per chunk, so routing stays cheap no matter how many partitions the subscriber has. + * Has to be called before the routed messages are flushed, otherwise their inbox has no partition. + */ + public function createPartitions(): void + { + foreach (array_chunk($this->unknownPartitions, self::LOOKUP_CHUNK, true) as $chunk) { + $existing = $this->subscriptionManager->find( + new SubscriptionCriteria(ids: array_keys($chunk), partitions: true), + ); + + foreach ($existing as $partition) { + $this->knownPartitions[$partition->id()] = true; + } + + foreach ($chunk as $partitionId => [$main, $partitionKey, $firstIndex]) { + if (isset($this->knownPartitions[$partitionId])) { + continue; + } + + $this->subscriptionManager->add(Subscription::partition($main, $partitionKey, $firstIndex - 1)); + $this->knownPartitions[$partitionId] = true; + + $this->logger?->info(sprintf( + 'Subscription Engine: Partition "%s" of subscriber "%s" created.', + $partitionKey, + $main->id(), + )); + } + } + + $this->unknownPartitions = []; + } + /** Whether messages were routed, then the partitions have new work. */ public function hasRouted(): bool { @@ -103,38 +131,4 @@ private function partitionKey(Message $message, Subscription $main, MetadataSubs return $partitionKey; } - - /** - * Looks up only the partitions the routed messages belong to, so routing a few messages stays cheap no matter how - * many partitions the subscriber has. When many messages are routed, e.g. while catching up, all partitions are - * loaded with one query instead. - */ - private function exists(Subscription $main, string $partitionId): bool - { - if (isset($this->knownPartitions[$partitionId])) { - return true; - } - - if (isset($this->loaded[$main->id()])) { - return false; - } - - $this->lookups[$main->id()] = ($this->lookups[$main->id()] ?? 0) + 1; - - if ($this->lookups[$main->id()] > self::SINGLE_LOOKUPS) { - foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)) as $partition) { - $this->knownPartitions[$partition->id()] = true; - } - - $this->loaded[$main->id()] = true; - - return isset($this->knownPartitions[$partitionId]); - } - - if ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$partitionId], partitions: true)) === []) { - return false; - } - - return $this->knownPartitions[$partitionId] = true; - } } diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 05e467ed6..ff4178b8b 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -163,6 +163,8 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals } } finally { $stream?->close(); + // the routed messages are flushed in any case, so their partitions have to exist as well + $router->createPartitions(); } } diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index e6b245165..7064e1b18 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -41,6 +41,7 @@ use PHPUnit\Framework\Attributes\CoversNothing; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use RuntimeException; use function array_keys; use function array_map; @@ -350,6 +351,64 @@ public function testRemovedMessagesDoNotBlockThePartition(): void ); } + /** More new keys than one lookup query covers, then messages for the existing partitions. */ + public function testManyPartitionsAreLookedUpInChunks(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(600); + $this->runUntilFinished($engine); + + self::assertCount(600, $this->partitions()); + + foreach ($ids as $id) { + $this->changeName(ProfileId::fromString($id), 'changed-' . $id); + } + + $this->runUntilFinished($engine); + + self::assertCount(600, $this->partitions()); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i, 'changed-' . $id], $projection->processed[$id]); + } + } + + /** The messages routed before an error of the main subscription keep their partitions. */ + public function testPartitionsOfMessagesRoutedBeforeAnErrorAreCreated(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + [$firstId, $secondId, $brokenId] = $this->createProfiles(3); + + $projection->partitionKey = static function (Message $message) use ($brokenId): string { + $streamName = $message->header(StreamNameHeader::class)->streamName; + + if ($streamName === 'profile-' . $brokenId) { + throw new RuntimeException('no key'); + } + + return $streamName; + }; + + $engine->execute(new Run()); + + self::assertSame(Status::Error, $this->subscriptionStore->get(self::MAIN)->status()); + self::assertSame( + [ + Subscription::partitionId(self::MAIN, 'profile-' . $firstId), + Subscription::partitionId(self::MAIN, 'profile-' . $secondId), + ], + array_keys($this->partitions()), + ); + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { From 8963e57d42e7a3603cd15ce4d7100571597dfab2 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:24:56 +0200 Subject: [PATCH 18/57] Process the routed messages in the same run The snapshot of a run was taken before the main subscriptions routed, so the partitions only processed the new messages in the next run. A single run, like the one of RunSubscriptionEngineRepository after a save, left a partitioned projection behind. Run and boot now claim the main subscriptions first and find the partitions with pending messages afterwards. This costs no extra query, the store already looked them up separately. A run is no longer unfinished just because it routed messages. --- .../Engine/Handler/BootHandler.php | 57 +++++++++++++----- .../Engine/Handler/RunHandler.php | 58 +++++++++++++++---- src/Subscription/Engine/PartitionRouter.php | 14 +---- .../Engine/SubscriptionRunner.php | 2 +- .../PartitionedSubscriptionTest.php | 38 ++++++++---- 5 files changed, 122 insertions(+), 47 deletions(-) diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 7cb31534d..145193914 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -41,25 +41,43 @@ public function __invoke(Command $command): ProcessedResult $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, ); - /** @var list $results */ - $results = $this->subscriptionManager->forEachClaimedGroup( + if (!$supportsPartitions) { + return ProcessedResult::merge($this->claim( + new SubscriptionCriteria(ids: $command->ids, groups: $command->groups, status: [Status::Booting]), + $claimGroups, + $command, + )); + } + + $results = $this->claim( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, status: [Status::Booting], - // partitions without messages in their inbox have nothing to do while their main subscription boots - pendingPartitionsOnly: $supportsPartitions, + partitions: false, ), $claimGroups, - fn (array $subscriptions): ProcessedResult => $this->process($subscriptions, $command), - $this->onError(...), + $command, ); - if ($supportsPartitions) { - $results = [...$results, ...$this->finishPartitions($command, $claimGroups)]; - } + // the partitions are found after the main subscriptions routed, so they process the new messages right away. + // partitions without messages in their inbox have nothing to do while their main subscription boots. + $results = [ + ...$results, + ...$this->claim( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Booting], + partitions: true, + pendingPartitionsOnly: true, + ), + $claimGroups, + $command, + ), + ]; - return ProcessedResult::merge($results); + return ProcessedResult::merge([...$results, ...$this->finishPartitions($command, $claimGroups)]); } /** @@ -94,17 +112,30 @@ private function finishPartitions(Boot $command, ClaimGroups $claimGroups): arra } return [ - ...$this->subscriptionManager->forEachClaimedGroup( + ...$this->claim( $criteria, $claimGroups, - fn (array $subscriptions): ProcessedResult => $this->process($subscriptions, $command), - $this->onError(...), + $command, ), // a partition held by another worker is not claimed, the next boot run checks again new ProcessedResult(0, false), ]; } + /** @return list */ + private function claim(SubscriptionCriteria $criteria, ClaimGroups $claimGroups, Boot $command): array + { + /** @var list $results */ + $results = $this->subscriptionManager->forEachClaimedGroup( + $criteria, + $claimGroups, + fn (array $subscriptions): ProcessedResult => $this->process($subscriptions, $command), + $this->onError(...), + ); + + return $results; + } + /** @param non-empty-list $subscriptions */ private function process(array $subscriptions, Boot $command): ProcessedResult { diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index d8ceec973..8205ec052 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -33,21 +33,59 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { - /** @var list $results */ - $results = $this->subscriptionManager->forEachClaimedGroup( + $claimGroups = new ClaimGroups( + $command->subscriptionsPerClaim, + $command->partitionsPerClaim, + // the group reads the stream from its lowest position, a larger gap would use up the limit of the others + $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, + ); + + // only a store with an inbox has partitions, every other store does not need to know the filters + if (!$this->subscriptionManager->supportsPartitions()) { + return ProcessedResult::merge($this->process( + new SubscriptionCriteria(ids: $command->ids, groups: $command->groups, status: [Status::Active]), + $claimGroups, + $command, + )); + } + + $results = $this->process( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, status: [Status::Active], - // only a store with an inbox has partitions, every other store does not need to know the filter - pendingPartitionsOnly: $this->subscriptionManager->supportsPartitions(), + partitions: false, ), - new ClaimGroups( - $command->subscriptionsPerClaim, - $command->partitionsPerClaim, - // the group reads the stream from its lowest position, a larger gap would use up the limit of the others - $command->limit ?? ClaimGroups::DEFAULT_POSITION_WINDOW, + $claimGroups, + $command, + ); + + // the partitions are found after the main subscriptions routed, so they process the new messages right away + $results = [ + ...$results, + ...$this->process( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Active], + partitions: true, + pendingPartitionsOnly: true, + ), + $claimGroups, + $command, ), + ]; + + return ProcessedResult::merge($results); + } + + /** @return list */ + private function process(SubscriptionCriteria $criteria, ClaimGroups $claimGroups, Run $command): array + { + /** @var list $results */ + $results = $this->subscriptionManager->forEachClaimedGroup( + $criteria, + $claimGroups, fn (array $subscriptions): ProcessedResult => $this->runner->process( $subscriptions, $command->limit, @@ -62,6 +100,6 @@ public function __invoke(Command $command): ProcessedResult ), ); - return ProcessedResult::merge($results); + return $results; } } diff --git a/src/Subscription/Engine/PartitionRouter.php b/src/Subscription/Engine/PartitionRouter.php index 9082c38b8..c0808c3ea 100644 --- a/src/Subscription/Engine/PartitionRouter.php +++ b/src/Subscription/Engine/PartitionRouter.php @@ -41,8 +41,6 @@ final class PartitionRouter /** @var array partition id => main subscription, key and first routed index */ private array $unknownPartitions = []; - private bool $routed = false; - public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly LoggerInterface|null $logger = null, @@ -58,11 +56,11 @@ public function route(int $index, Message $message, Subscription $main, Metadata // fails first if the store has no inbox, so no partition is created that could never be processed $this->subscriptionManager->addToInbox($partitionId, $index); - if (!isset($this->knownPartitions[$partitionId]) && !isset($this->unknownPartitions[$partitionId])) { - $this->unknownPartitions[$partitionId] = [$main, $partitionKey, $index]; + if (isset($this->knownPartitions[$partitionId]) || isset($this->unknownPartitions[$partitionId])) { + return; } - $this->routed = true; + $this->unknownPartitions[$partitionId] = [$main, $partitionKey, $index]; } /** @@ -100,12 +98,6 @@ public function createPartitions(): void $this->unknownPartitions = []; } - /** Whether messages were routed, then the partitions have new work. */ - public function hasRouted(): bool - { - return $this->routed; - } - /** @param MetadataSubscriberAccessor $subscriber */ private function partitionKey(Message $message, Subscription $main, MetadataSubscriberAccessor $subscriber): string { diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index ff4178b8b..c12b5dde4 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -195,7 +195,7 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals return new ProcessedResult( $messageCounter, - !$limitReached && !$inboxFull && !$router->hasRouted(), + !$limitReached && !$inboxFull, $errors, ); } diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 7064e1b18..e045883e3 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -27,6 +27,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepository; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Status; @@ -86,29 +87,23 @@ public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void $result = $engine->execute(new Run()); self::assertInstanceOf(ProcessedResult::class, $result); - self::assertFalse($result->finished, 'the routed messages still have to be processed by the partitions'); - - self::assertSame(0, $projection->handled, 'the main subscription never calls the subscriber'); + self::assertTrue($result->finished, 'the partitions process the routed messages in the same run'); $partitions = $this->partitions(); self::assertCount(3, $partitions); - foreach ($ids as $id) { + foreach ($ids as $i => $id) { $partition = $partitions[Subscription::partitionId(self::MAIN, 'profile-' . $id)]; self::assertSame(self::MAIN, $partition->subscriberId()); self::assertSame('profile-' . $id, $partition->partitionKey()); self::assertSame(Status::Active, $partition->status()); - self::assertCount(1, $this->subscriptionStore->inbox([$partition->id() => -1], 10)[$partition->id()] ?? []); - } - - $this->runUntilFinished($engine); - - foreach ($ids as $i => $id) { self::assertSame(['name-' . $i], $projection->processed[$id]); self::assertSame('profile-' . $id, $projection->partitionKeys[$id], 'the handler gets the key of its partition'); } + self::assertSame(3, $projection->handled, 'only the partitions call the subscriber, every message once'); + foreach ($this->partitions() as $partition) { self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10), 'processed messages leave the inbox'); } @@ -328,13 +323,16 @@ public function testRemovedMessagesDoNotBlockThePartition(): void [$removedId, $keptId] = $this->createProfiles(2); $this->runUntilFinished($engine); + // the partition fails, so the message is still in its inbox when its stream is removed + $projection->failingNames = ['removed']; $this->changeName(ProfileId::fromString($removedId), 'removed'); $this->changeName(ProfileId::fromString($keptId), 'kept'); - - $engine->execute(new Run()); // the main subscription routes both messages + $this->runUntilFinished($engine); $this->store->remove(new Criteria(new StreamCriterion('profile-' . $removedId))); + $projection->failingNames = []; + $this->clock->sleep(10); $this->runUntilFinished($engine); self::assertSame(['name-1', 'kept'], $projection->processed[$keptId]); @@ -409,6 +407,22 @@ public function testPartitionsOfMessagesRoutedBeforeAnErrorAreCreated(): void ); } + /** The repository that runs the engine after every save, e.g. to read your own writes. */ + public function testProjectionIsUpToDateAfterASynchronousRun(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $engine->execute(new Setup(skipBooting: true)); + + $repository = new RunSubscriptionEngineRepository($this->repository, $engine); + + $id = ProfileId::generate(); + $repository->save(Profile::create($id, 'name')); + + self::assertSame(['name'], $projection->processed[$id->toString()]); + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { From 3ce0006b9da83fc1c246460a1cbd3a0f0809c031 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:33:41 +0200 Subject: [PATCH 19/57] Only set back partitions when the circuit breaker stopped the subscriber If another worker held the main subscription, the breaker could not stop it but still set back the failed partitions. They started again right away, without backoff and without using up retries. Now the partitions are only set back if the main subscription is stopped, by this or another worker. A partition without retries left called its #[OnFailed] method before the breaker decided, which could skip the message during a systemic failure. That transition is now held back until the end of the run and dropped if the breaker trips. The failed handling moved from the FailSubscriber into the FailedMessageProcessor, so the breaker can use it without depending on another listener. The breaker is renamed to PartitionCircuitBreakerSubscriber. --- .../Engine/DefaultSubscriptionEngine.php | 19 +-- .../Engine/FailedMessageProcessor.php | 85 ++++++++++++++ .../Engine/Listener/FailSubscriber.php | 71 +---------- ... => PartitionCircuitBreakerSubscriber.php} | 110 ++++++++++++++++-- .../Engine/Handler/BootHandlerTest.php | 3 +- .../Engine/Handler/RunHandlerTest.php | 3 +- .../Engine/Listener/BatchSubscriberTest.php | 5 +- .../Engine/Listener/FailSubscriberTest.php | 6 +- ...PartitionCircuitBreakerSubscriberTest.php} | 109 +++++++++++++++-- 9 files changed, 312 insertions(+), 99 deletions(-) create mode 100644 src/Subscription/Engine/FailedMessageProcessor.php rename src/Subscription/Engine/Listener/{PartitionCircuitBreaker.php => PartitionCircuitBreakerSubscriber.php} (69%) rename tests/Unit/Subscription/Engine/Listener/{PartitionCircuitBreakerTest.php => PartitionCircuitBreakerSubscriberTest.php} (64%) diff --git a/src/Subscription/Engine/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index 7aa5ce774..897e3fbff 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -31,7 +31,7 @@ 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\PartitionCircuitBreaker; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\PartitionCircuitBreakerSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RemovePartitionsListener; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; @@ -169,11 +169,18 @@ public function __construct( ), ); + $failedMessageProcessor = new FailedMessageProcessor( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ); + $this->eventDispatcher->addSubscriber( - new PartitionCircuitBreaker( + new PartitionCircuitBreakerSubscriber( $this->subscriptionManager, $this->subscriberRepository, $this->retryStrategyRepository, + $failedMessageProcessor, $this->logger, ), ); @@ -194,13 +201,7 @@ public function __construct( ), ); - $this->eventDispatcher->addSubscriber( - new FailSubscriber( - $this->subscriptionManager, - $this->subscriberRepository, - $this->logger, - ), - ); + $this->eventDispatcher->addSubscriber(new FailSubscriber($failedMessageProcessor)); $this->eventDispatcher->addListener( OnCommand::class, diff --git a/src/Subscription/Engine/FailedMessageProcessor.php b/src/Subscription/Engine/FailedMessageProcessor.php new file mode 100644 index 000000000..9033813c8 --- /dev/null +++ b/src/Subscription/Engine/FailedMessageProcessor.php @@ -0,0 +1,85 @@ +failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); + + if (!$subscriber) { + $subscription->failed($throwable); + $this->subscriptionManager->update($subscription); + + return; + } + + if ($subscriber->metadata()->batch !== null) { + $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/FailSubscriber.php b/src/Subscription/Engine/Listener/FailSubscriber.php index c28de2f88..71d006935 100644 --- a/src/Subscription/Engine/Listener/FailSubscriber.php +++ b/src/Subscription/Engine/Listener/FailSubscriber.php @@ -4,90 +4,25 @@ 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\SubscriberAccessorRepository; -use Patchlevel\EventSourcing\Subscription\Subscription; -use Psr\Log\LoggerInterface; +use Patchlevel\EventSourcing\Subscription\Engine\FailedMessageProcessor; use Symfony\Component\EventDispatcher\EventSubscriberInterface; -use Throwable; - -use function sprintf; /** @internal */ final class FailSubscriber implements EventSubscriberInterface { public function __construct( - private readonly SubscriptionManager $subscriptionManager, - private readonly SubscriberAccessorRepository $subscriberRepository, - private readonly LoggerInterface|null $logger = null, + private readonly FailedMessageProcessor $failedMessageProcessor, ) { } - public 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->subscriberRepository->get($subscription->subscriberId()); - - if (!$subscriber) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - if ($subscriber->metadata()->batch !== null) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - $failedMethod = $subscriber->failedMethod(); - - if (!$failedMethod) { - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - - return; - } - - try { - $failedMethod($message, $throwable); - $subscription->changePosition($index); - $subscription->resetRetry(); - - $this->subscriptionManager->update($subscription); - } catch (Throwable $e) { - $this->logger?->error(sprintf( - 'Subscription Engine: Subscriber "%s" has an error in the failed method: %s', - $subscription->id(), - $e->getMessage(), - )); - - $subscription->failed($throwable); - $this->subscriptionManager->update($subscription); - } - } - public function onHandleMessageError(OnHandleMessageError $event): void { if (!$event->transitionToFailed) { return; } - $this->handleFailed($event->subscription, $event->throwable, $event->message, $event->index); + $this->failedMessageProcessor->process($event->subscription, $event->throwable, $event->message, $event->index); } /** @return array */ diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreaker.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php similarity index 69% rename from src/Subscription/Engine/Listener/PartitionCircuitBreaker.php rename to src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php index 04d92cc73..fef828042 100644 --- a/src/Subscription/Engine/Listener/PartitionCircuitBreaker.php +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Listener; +use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; @@ -11,6 +12,7 @@ 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\FailedMessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ConditionalRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; @@ -36,7 +38,7 @@ * * @internal */ -final class PartitionCircuitBreaker implements EventSubscriberInterface +final class PartitionCircuitBreakerSubscriber implements EventSubscriberInterface { private bool $active = false; @@ -52,10 +54,14 @@ final class PartitionCircuitBreaker implements EventSubscriberInterface /** @var array subscriber ids with a successful partition */ private array $succeeded = []; + /** @var array> subscriber id => partition id => held back failure */ + private array $heldBackFailures = []; + public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, private readonly RetryStrategyRepository $retryStrategyRepository, + private readonly FailedMessageProcessor $failedMessageProcessor, private readonly LoggerInterface|null $logger = null, ) { } @@ -67,6 +73,7 @@ public function onCommand(OnCommand $event): void $this->failedPartitions = []; $this->tripped = []; $this->succeeded = []; + $this->heldBackFailures = []; } public function onHandleMessage(OnHandleMessage $event): void @@ -99,6 +106,30 @@ public function onHandleMessageError(OnHandleMessageError $event): void $this->failure($event->subscription, $event->throwable); } + /** + * A partition without retries left would go into the failed status and call its #[OnFailed] method, which may + * skip the message. During a systemic failure that must not happen, so the transition is held back until the end + * of the command: the partition goes into the error status for now and only fails if the breaker did not trip. + */ + public function holdBackFailure(OnHandleMessageError $event): void + { + if (!$this->active || !$event->transitionToFailed || !$event->subscription->isPartition()) { + return; + } + + $partition = $event->subscription; + + $event->transitionToFailed = false; + $partition->error($event->throwable); + $this->subscriptionManager->update($partition); + + $this->heldBackFailures[$partition->subscriberId()][$partition->id()] = [ + $event->throwable, + $event->message, + $event->index, + ]; + } + /** * Failures outside of a message, like a failing batch flush or a whole group, only show up in the result. * Then the main subscriptions of tripped subscribers go into error and their failed partitions are set back. @@ -127,7 +158,18 @@ public function onResult(OnResult $event): void } foreach ($this->tripped as $subscriberId => $throwable) { - $this->stopSubscriber($subscriberId, $throwable); + if ($this->stopSubscriber($subscriberId, $throwable)) { + // the partitions did not fail on their own, they are set back + unset($this->heldBackFailures[$subscriberId]); + + continue; + } + + unset($this->tripped[$subscriberId]); + } + + foreach ($this->heldBackFailures as $failures) { + $this->fail($failures); } $this->resetRetries(); @@ -174,11 +216,15 @@ private function isRecorded(string $subscriptionId): bool return false; } - private function stopSubscriber(string $subscriberId, Throwable $throwable): void + /** + * Returns whether the main subscription is stopped. Only then the failed partitions are set back, otherwise they + * would start again right away without any backoff. + */ + private function stopSubscriber(string $subscriberId, Throwable $throwable): bool { - $this->subscriptionManager->forEachClaimed( + $stopped = $this->subscriptionManager->forEachClaimed( new SubscriptionCriteria(ids: [$subscriberId], status: [Status::Active, Status::Booting], partitions: false), - function (Subscription $main) use ($throwable): void { + function (Subscription $main) use ($throwable): bool { if ($this->canRetry($main)) { $main->error($throwable); } else { @@ -186,13 +232,24 @@ function (Subscription $main) use ($throwable): void { } $this->subscriptionManager->update($main); + + return true; }, ); + if ($stopped === [] && !$this->isStopped($subscriberId)) { + $this->logger?->warning(sprintf( + 'Subscription Engine: The main subscription of subscriber "%s" could not be stopped, it is held by another worker.', + $subscriberId, + )); + + return false; + } + $partitionIds = array_keys($this->failedPartitions[$subscriberId] ?? []); if ($partitionIds === []) { - return; + return true; } // the partitions did not fail on their own, they continue where they were once the subscriber runs again @@ -208,6 +265,39 @@ function (Subscription $partition): void { $this->subscriptionManager->update($partition); }, ); + + return true; + } + + /** E.g. by another worker whose breaker tripped as well. */ + private function isStopped(string $subscriberId): bool + { + foreach ($this->subscriptionManager->find(new SubscriptionCriteria(ids: [$subscriberId], partitions: false)) as $main) { + if ($main->isError() || $main->isFailed()) { + return true; + } + } + + return false; + } + + /** @param array $failures partition id => held back failure */ + private function fail(array $failures): void + { + $this->subscriptionManager->forEachClaimed( + new SubscriptionCriteria(ids: array_keys($failures), status: [Status::Error], partitions: true), + function (Subscription $partition) use ($failures): void { + if (!isset($failures[$partition->id()])) { + return; + } + + [$throwable, $message, $index] = $failures[$partition->id()]; + + // back to the status before the error, the failed message processor decides about the rest + $partition->doRetry(); + $this->failedMessageProcessor->process($partition, $throwable, $message, $index); + }, + ); } /** The main subscription only routes, its retries are reset once its partitions succeed again. */ @@ -247,7 +337,7 @@ private function canRetry(Subscription $main): bool return !$retryStrategy instanceof ConditionalRetryStrategy || $retryStrategy->canRetry($main); } - /** @return array */ + /** @return array> */ public static function getSubscribedEvents(): array { return [ @@ -255,7 +345,11 @@ public static function getSubscribedEvents(): array // before the batch subscriber, a skipped message must not open a batch OnHandleMessage::class => ['onHandleMessage', 32], OnHandleMessageSuccess::class => 'onHandleMessageSuccess', - OnHandleMessageError::class => 'onHandleMessageError', + OnHandleMessageError::class => [ + ['onHandleMessageError', 0], + // after the retry subscriber decided about the transition, before the fail subscriber executes it + ['holdBackFailure', -4], + ], OnResult::class => 'onResult', ]; } diff --git a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php index e0ae4fa5b..edc9b6864 100644 --- a/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/BootHandlerTest.php @@ -14,6 +14,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot as BootCommand; +use Patchlevel\EventSourcing\Subscription\Engine\FailedMessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\Handler\BootHandler; use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; @@ -60,7 +61,7 @@ private function createHandler( $eventDispatcher = new EventDispatcher(); $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); - $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber(new FailedMessageProcessor($subscriptionManager, $subscriberRepository, new NullLogger()))); $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, [], new NullLogger()); diff --git a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php index 45c211a84..ed745816e 100644 --- a/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php +++ b/tests/Unit/Subscription/Engine/Handler/RunHandlerTest.php @@ -12,6 +12,7 @@ use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run as RunCommand; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; +use Patchlevel\EventSourcing\Subscription\Engine\FailedMessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\Handler\RunHandler; use Patchlevel\EventSourcing\Subscription\Engine\Listener\DetachListener; use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailSubscriber; @@ -62,7 +63,7 @@ private function createHandler( $eventDispatcher = new EventDispatcher(); $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); - $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber(new FailedMessageProcessor($subscriptionManager, $subscriberRepository, new NullLogger()))); $eventDispatcher->addListener(OnCommand::class, new DetachListener($subscriptionManager, $subscriberRepository, new NullLogger()), 32); $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, [], new NullLogger()); diff --git a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php index 7e3ff7e2b..492d9b9d3 100644 --- a/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/BatchSubscriberTest.php @@ -15,6 +15,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot as BootCommand; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run as RunCommand; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; +use Patchlevel\EventSourcing\Subscription\Engine\FailedMessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\Handler\BootHandler; use Patchlevel\EventSourcing\Subscription\Engine\Handler\RunHandler; use Patchlevel\EventSourcing\Subscription\Engine\Listener\BatchSubscriber; @@ -71,7 +72,7 @@ private function createBootHandler( $eventDispatcher->addSubscriber(new BatchSubscriber($batchManager, $subscriberRepository, new NullLogger())); $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); - $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber(new FailedMessageProcessor($subscriptionManager, $subscriberRepository, new NullLogger()))); $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, [new BatchArgumentResolver($batchManager)], new NullLogger()); @@ -102,7 +103,7 @@ private function createRunHandler( $eventDispatcher->addSubscriber(new BatchSubscriber($batchManager, $subscriberRepository, new NullLogger())); $eventDispatcher->addSubscriber(new RetrySubscriber($subscriptionManager, $subscriberRepository, $retryStrategyRepository, new NullLogger())); - $eventDispatcher->addSubscriber(new FailSubscriber($subscriptionManager, $subscriberRepository, new NullLogger())); + $eventDispatcher->addSubscriber(new FailSubscriber(new FailedMessageProcessor($subscriptionManager, $subscriberRepository, new NullLogger()))); $eventDispatcher->addListener(OnCommand::class, new DetachListener($subscriptionManager, $subscriberRepository, new NullLogger()), 32); $messageProcessor = new MessageProcessor($subscriberRepository, $eventDispatcher, [new BatchArgumentResolver($batchManager)], new NullLogger()); diff --git a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php index 409f90b97..97b29ae6b 100644 --- a/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/FailSubscriberTest.php @@ -13,6 +13,7 @@ use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; +use Patchlevel\EventSourcing\Subscription\Engine\FailedMessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\Listener\FailSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -31,6 +32,7 @@ use stdClass; #[CoversClass(FailSubscriber::class)] +#[CoversClass(FailedMessageProcessor::class)] final class FailSubscriberTest extends TestCase { /** @@ -43,11 +45,11 @@ private function createListener(DummySubscriptionStore $store, array $subscriber $subscriptionManager = new SubscriptionManager($store); return [ - new FailSubscriber( + new FailSubscriber(new FailedMessageProcessor( $subscriptionManager, new MetadataSubscriberAccessorRepository($subscribers), new NullLogger(), - ), + )), $subscriptionManager, ]; } diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php similarity index 64% rename from tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php rename to tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php index 3747ecd4b..11c4bb3df 100644 --- a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -16,7 +16,8 @@ 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\Listener\PartitionCircuitBreaker; +use Patchlevel\EventSourcing\Subscription\Engine\FailedMessageProcessor; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\PartitionCircuitBreakerSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; @@ -35,8 +36,8 @@ use function array_map; use function range; -#[CoversClass(PartitionCircuitBreaker::class)] -final class PartitionCircuitBreakerTest extends TestCase +#[CoversClass(PartitionCircuitBreakerSubscriber::class)] +final class PartitionCircuitBreakerSubscriberTest extends TestCase { private InMemorySubscriptionStore $store; @@ -119,6 +120,94 @@ public function testFailedGroupInTheResultTripsTheBreaker(): void } } + public function testPartitionsStayInErrorIfTheMainSubscriptionCanNotBeStopped(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + // e.g. claimed by another worker, here simply no longer in a status the breaker stops + $main = $this->store->get('profile'); + $main->pause(); + $this->store->update($main); + + foreach ([0, 1, 2] as $i) { + $this->partitions[$i]->error(new RuntimeException('database down')); + $this->store->update($this->partitions[$i]); + $breaker->onHandleMessageError($this->error($this->partitions[$i])); + } + + $breaker->onResult(new OnResult(new Run(), new Result())); + + self::assertSame(Status::Paused, $this->store->get('profile')->status()); + + foreach ([0, 1, 2] as $i) { + self::assertSame(Status::Error, $this->store->get($this->partitions[$i]->id())->status(), 'no restart without backoff'); + } + } + + public function testPartitionsAreSetBackIfTheMainSubscriptionIsAlreadyStopped(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $main = $this->store->get('profile'); + $main->error(new RuntimeException('stopped by another worker')); + $this->store->update($main); + + foreach ([0, 1, 2] as $i) { + $this->partitions[$i]->error(new RuntimeException('database down')); + $this->store->update($this->partitions[$i]); + $breaker->onHandleMessageError($this->error($this->partitions[$i])); + } + + $breaker->onResult(new OnResult(new Run(), new Result())); + + foreach ([0, 1, 2] as $i) { + self::assertSame(Status::Active, $this->store->get($this->partitions[$i]->id())->status()); + } + } + + public function testHeldBackFailureIsDroppedIfTheBreakerTrips(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + foreach ([0, 1, 2] as $i) { + $event = $this->error($this->partitions[$i]); + $event->transitionToFailed = true; + + $breaker->onHandleMessageError($event); + $breaker->holdBackFailure($event); + + self::assertFalse($event->transitionToFailed, 'the fail subscriber does not see it'); + self::assertSame(Status::Error, $this->partitions[$i]->status()); + } + + $breaker->onResult(new OnResult(new Run(), new Result())); + + foreach ([0, 1, 2] as $i) { + self::assertSame(Status::Active, $this->store->get($this->partitions[$i]->id())->status(), 'set back, not failed'); + } + } + + public function testHeldBackFailureFailsThePartitionWithoutTrip(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $event = $this->error($this->partitions[0]); + $event->transitionToFailed = true; + + $breaker->onHandleMessageError($event); + $breaker->holdBackFailure($event); + $this->store->update($this->partitions[0]); + + $breaker->onResult(new OnResult(new Run(), new Result())); + + self::assertSame(Status::Active, $this->store->get('profile')->status()); + self::assertSame(Status::Failed, $this->store->get($this->partitions[0]->id())->status()); + } + public function testOnlyRunAndBoot(): void { $breaker = $this->breaker(1); @@ -131,7 +220,7 @@ public function testOnlyRunAndBoot(): void } /** @param positive-int $maxConsecutiveFailures */ - private function breaker(int $maxConsecutiveFailures): PartitionCircuitBreaker + private function breaker(int $maxConsecutiveFailures): PartitionCircuitBreakerSubscriber { $subscriber = $maxConsecutiveFailures === 1 ? new #[Subscriber('profile', RunMode::FromBeginning)] @@ -169,10 +258,14 @@ public function on(ProfileVisited $event): void $this->store = new InMemorySubscriptionStore([$main, ...$this->partitions]); - return new PartitionCircuitBreaker( - new SubscriptionManager($this->store), - new MetadataSubscriberAccessorRepository([$subscriber]), + $subscriptionManager = new SubscriptionManager($this->store); + $subscriberRepository = new MetadataSubscriberAccessorRepository([$subscriber]); + + return new PartitionCircuitBreakerSubscriber( + $subscriptionManager, + $subscriberRepository, RetryStrategyRepository::withDefault(new NoRetryStrategy()), + new FailedMessageProcessor($subscriptionManager, $subscriberRepository), ); } @@ -181,7 +274,7 @@ private function error(Subscription $partition): OnHandleMessageError return new OnHandleMessageError($partition, new RuntimeException('database down'), $this->message(), 1); } - private function handle(PartitionCircuitBreaker $breaker, Subscription $partition): OnHandleMessage + private function handle(PartitionCircuitBreakerSubscriber $breaker, Subscription $partition): OnHandleMessage { $event = new OnHandleMessage($partition, $this->message()); $breaker->onHandleMessage($event); From 22c72ea7a014c1f77af6edfae284f0dfbe79ca53 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:33:49 +0200 Subject: [PATCH 20/57] Update the circuit breaker note about #[OnFailed] --- docs/subscription.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/subscription.md b/docs/subscription.md index a6ae8da8b..3c7b68883 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -965,7 +965,8 @@ final class ProfileProjector } ``` :::note -A partition that already used up its retries before the outage may still call its `#[OnFailed]` method. +A partition without retries left only fails and calls its `#[OnFailed]` method at the end of the run, +if the circuit breaker did not trip. ::: ## Subscription Engine From e03bf6cacec82eb4236655c24004a8105ea21445 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:42:24 +0200 Subject: [PATCH 21/57] Compare subscription ids binary on MySQL and MariaDB Partition keys come from the messages. The default collations of MySQL and MariaDB treat keys that only differ in case as equal, so a second partition could not be created and the run failed with SubscriptionAlreadyExists. The id, subscriber id and partition key columns and the subscription id of the inbox now use utf8mb4_bin there. --- docs/UPGRADE-4.0.md | 14 +++++- .../Store/DoctrineSubscriptionStore.php | 28 ++++++++++-- .../PartitionedSubscriptionTest.php | 43 +++++++++++++++++++ 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 27c84b6a9..6ee9444c2 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -504,7 +504,19 @@ ALTER TABLE subscriptions ALTER COLUMN subscriber_id SET NOT NULL; CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` -Adjust the table names if you configured your own, and the `ALTER COLUMN` syntax for MySQL (`MODIFY`). +On MySQL and MariaDB the ids and the partition key are compared binary, so that partition keys that only differ in +case are different partitions: + +```sql +ALTER TABLE subscriptions ADD subscriber_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL; +ALTER TABLE subscriptions ADD partition_key VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL; +UPDATE subscriptions SET subscriber_id = id; +ALTER TABLE subscriptions MODIFY subscriber_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; +ALTER TABLE subscriptions MODIFY id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; +CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id); +CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); +``` +Adjust the table names if you configured your own. A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()` and support the new `partitions` and `pendingPartitionsOnly` criteria. To support partitions, it also has to implement the diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 072730bef..ad037a95a 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -12,6 +12,7 @@ use Doctrine\DBAL\Exception\RetryableException; use Doctrine\DBAL\Exception\UniqueConstraintViolationException; use Doctrine\DBAL\ParameterType; +use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Platforms\SQLitePlatform; @@ -737,7 +738,8 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->addColumn('id', Types::STRING) ->setLength(255) - ->setNotnull(true); + ->setNotnull(true) + ->setPlatformOptions($this->caseSensitive($connection)); $table->addColumn('group_name', Types::STRING) ->setLength(32) ->setNotnull(true); @@ -765,10 +767,12 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->addColumn('subscriber_id', Types::STRING) ->setLength(255) - ->setNotnull(true); + ->setNotnull(true) + ->setPlatformOptions($this->caseSensitive($connection)); $table->addColumn('partition_key', Types::STRING) ->setLength(255) - ->setNotnull(false); + ->setNotnull(false) + ->setPlatformOptions($this->caseSensitive($connection)); $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); @@ -779,13 +783,29 @@ public function configureSchema(Schema $schema, Connection $connection): void $inbox->addColumn('subscription_id', Types::STRING) ->setLength(255) - ->setNotnull(true); + ->setNotnull(true) + ->setPlatformOptions($this->caseSensitive($connection)); $inbox->addColumn('message_index', Types::INTEGER) ->setNotnull(true); $inbox->setPrimaryKey(['subscription_id', 'message_index']); } + /** + * Partition keys come from the messages, keys that only differ in case or accents are different partitions. + * The default collations of MySQL and MariaDB compare them as equal, so the ids are compared binary there. + * + * @return array{charset?: non-empty-string, collation?: non-empty-string} + */ + private function caseSensitive(Connection $connection): array + { + if (!$connection->getDatabasePlatform() instanceof AbstractMySQLPlatform) { + return []; + } + + return ['charset' => 'utf8mb4', 'collation' => 'utf8mb4_bin']; + } + /** @param Data $row */ private function createSubscription(array $row): Subscription { diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index e045883e3..bbea1c091 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -36,6 +36,7 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\DbalManager; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedBatchProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedTransactionalBatchProjection; @@ -46,6 +47,7 @@ use function array_keys; use function array_map; +use function array_values; use function gc_collect_cycles; use function random_int; use function sort; @@ -423,6 +425,47 @@ public function testProjectionIsUpToDateAfterASynchronousRun(): void self::assertSame(['name'], $projection->processed[$id->toString()]); } + /** @return iterable */ + public static function sameRun(): iterable + { + yield 'routed in the same run' => [true]; + yield 'routed in separate runs' => [false]; + } + + /** MySQL and MariaDB compare strings case insensitive by default, partition keys must not. */ + #[DataProvider('sameRun')] + public function testKeysThatOnlyDifferInCaseAreDifferentPartitions(bool $sameRun): void + { + $projection = new PartitionedProfileProjection(); + $projection->partitionKey = static function (Message $message): string { + $event = $message->event(); + + return $event instanceof ProfileCreated ? 'tenant-' . $event->name : 'other'; + }; + + $engine = $this->engine($projection); + $engine->execute(new Setup(skipBooting: true)); + + [$upperId] = $this->createProfiles(1, ['ACME']); + + if (!$sameRun) { + $this->runUntilFinished($engine); + } + + [$lowerId] = $this->createProfiles(1, ['acme']); + $this->runUntilFinished($engine); + + self::assertSame(['ACME'], $projection->processed[$upperId]); + self::assertSame(['acme'], $projection->processed[$lowerId]); + $keys = array_map( + static fn (Subscription $partition): string|null => $partition->partitionKey(), + array_values($this->partitions()), + ); + sort($keys); + + self::assertSame(['tenant-ACME', 'tenant-acme'], $keys); + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { From 4af31f454c4444b4dbe2ce2352b416f65e6b4e70 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 11:52:58 +0200 Subject: [PATCH 22/57] Stop a subscription for the rest of the run instead of skipping a message A listener could skip a message with OnHandleMessage::$skip, but the next message that was processed moved the position past it and the skipped message was lost. A batch even moved to the last index of the run on its flush. OnHandleMessage::$stop replaces it: the subscription is not processed any further in this run, its position stays in front of the message and a batch is only flushed up to the last message it handled. --- .../Engine/Event/OnHandleMessage.php | 8 +- .../Engine/Listener/BatchSubscriber.php | 4 +- .../PartitionCircuitBreakerSubscriber.php | 2 +- src/Subscription/Engine/MessageProcessor.php | 8 +- src/Subscription/Engine/ProcessingStopped.php | 14 ++++ .../Engine/SubscriptionRunner.php | 39 +++++++-- .../PartitionCircuitBreakerSubscriberTest.php | 8 +- .../Engine/MessageProcessorTest.php | 12 +-- .../Engine/SubscriptionRunnerTest.php | 84 ++++++++++++++++++- 9 files changed, 153 insertions(+), 26 deletions(-) create mode 100644 src/Subscription/Engine/ProcessingStopped.php diff --git a/src/Subscription/Engine/Event/OnHandleMessage.php b/src/Subscription/Engine/Event/OnHandleMessage.php index fb39058bc..3361f50e1 100644 --- a/src/Subscription/Engine/Event/OnHandleMessage.php +++ b/src/Subscription/Engine/Event/OnHandleMessage.php @@ -12,8 +12,12 @@ final class OnHandleMessage public function __construct( public readonly Subscription $subscription, public readonly Message $message, - /** A listener can skip the message: no subscribe method is called and the position does not move. */ - public bool $skip = false, + /** + * A listener can stop the processing of the subscription for the rest of the run: no subscribe method is + * called, the position stays in front of this message and the following messages are not processed either. + * They are processed in a later run. + */ + public bool $stop = false, ) { } } diff --git a/src/Subscription/Engine/Listener/BatchSubscriber.php b/src/Subscription/Engine/Listener/BatchSubscriber.php index d3de19af6..027c62fcd 100644 --- a/src/Subscription/Engine/Listener/BatchSubscriber.php +++ b/src/Subscription/Engine/Listener/BatchSubscriber.php @@ -39,8 +39,8 @@ public function onCommand(OnCommand $event): void public function onHandleMessage(OnHandleMessage $event): void { - // a skipped message must not open a batch, it may hold a transaction until the flush - if ($event->skip) { + // a stopped subscription must not open a batch, it may hold a transaction until the flush + if ($event->stop) { return; } diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php index fef828042..6d3e09f6c 100644 --- a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -82,7 +82,7 @@ public function onHandleMessage(OnHandleMessage $event): void return; } - $event->skip = true; + $event->stop = true; } public function onHandleMessageSuccess(OnHandleMessageSuccess $event): void diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index 3b09f0a7f..e3b2825d7 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -67,7 +67,7 @@ public function process( Message $message, Subscription $subscription, PartitionRouter|null $router = null, - ): Error|null { + ): Error|ProcessingStopped|null { $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); if (!$subscriber) { @@ -115,14 +115,14 @@ public function process( $this->eventDispatcher->dispatch($event); - if ($event->skip) { + if ($event->stop) { $this->logger?->debug(sprintf( - 'Subscription Engine: Subscription "%s" skipped the event "%s".', + 'Subscription Engine: Subscription "%s" stopped before the event "%s".', $subscription->id(), $message->event()::class, )); - return null; + return new ProcessingStopped(); } $context = new ArgumentResolverContext($message, $subscription, $subscriber->metadata()); diff --git a/src/Subscription/Engine/ProcessingStopped.php b/src/Subscription/Engine/ProcessingStopped.php new file mode 100644 index 000000000..9fe34e4df --- /dev/null +++ b/src/Subscription/Engine/ProcessingStopped.php @@ -0,0 +1,14 @@ + $loaded */ $loaded = []; + /** @var array $lastHandled subscription id => index of the last message it handled */ + $lastHandled = []; + /** @var list $stopped stopped by a listener for the rest of the run */ + $stopped = []; $limitReached = false; if (count($working) > 0) { @@ -136,14 +140,21 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $error = $this->messageProcessor->process($index, $message, $member, $router); - if (!$error) { + if ($error === null) { + $lastHandled[$member->id()] = $index; + continue; } - $errors[] = $error; $working->remove($member); $members->remove($member); + if ($error instanceof ProcessingStopped) { + $stopped[] = $member; + } else { + $errors[] = $error; + } + if (count($working) === 0) { break 2; } @@ -170,13 +181,20 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals if ($lastIndex !== null) { foreach ($working as $member) { - $event = new OnSubscriptionProcessed($member, $lastIndex); - $this->eventDispatcher->dispatch($event); - $errors = array_merge($errors, $event->errors); + $errors = array_merge($errors, $this->processed($member, $lastIndex)); } } - foreach ($working as $member) { + // a stopped subscription only gets as far as the messages it handled, a batch must not move it further + foreach ($stopped as $member) { + if (!isset($lastHandled[$member->id()])) { + continue; + } + + $errors = array_merge($errors, $this->processed($member, $lastHandled[$member->id()])); + } + + foreach ([...$working, ...$stopped] as $member) { if ($member->isPartition()) { // also without any loaded message, then every message of the inbox was removed from the store $this->finishInbox($member, $inboxes[$member->id()], $loaded); @@ -200,6 +218,15 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals ); } + /** @return list */ + private function processed(Subscription $subscription, int $lastIndex): array + { + $event = new OnSubscriptionProcessed($subscription, $lastIndex); + $this->eventDispatcher->dispatch($event); + + return $event->errors; + } + /** * @param list $subscriptions * diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php index 11c4bb3df..949fead54 100644 --- a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -53,12 +53,12 @@ public function testTripsAfterTheThresholdAndSkipsTheOtherPartitions(): void $breaker->onHandleMessageError($this->error($this->partitions[$i])); } - self::assertFalse($this->handle($breaker, $this->partitions[3])->skip, 'not tripped yet'); + self::assertFalse($this->handle($breaker, $this->partitions[3])->stop, 'not tripped yet'); $breaker->onHandleMessageError($this->error($this->partitions[2])); - self::assertTrue($this->handle($breaker, $this->partitions[3])->skip); - self::assertFalse($this->handle($breaker, new Subscription('other#a', partitionKey: 'a'))->skip, 'other subscribers go on'); + self::assertTrue($this->handle($breaker, $this->partitions[3])->stop); + self::assertFalse($this->handle($breaker, new Subscription('other#a', partitionKey: 'a'))->stop, 'other subscribers go on'); } public function testSuccessResetsTheFailuresInARow(): void @@ -72,7 +72,7 @@ public function testSuccessResetsTheFailuresInARow(): void $breaker->onHandleMessageError($this->error($this->partitions[3])); $breaker->onHandleMessageError($this->error($this->partitions[4])); - self::assertFalse($this->handle($breaker, $this->partitions[5])->skip); + self::assertFalse($this->handle($breaker, $this->partitions[5])->stop); } public function testStopsTheMainSubscriptionAndSetsBackThePartitions(): void diff --git a/tests/Unit/Subscription/Engine/MessageProcessorTest.php b/tests/Unit/Subscription/Engine/MessageProcessorTest.php index e4befc57d..96d498009 100644 --- a/tests/Unit/Subscription/Engine/MessageProcessorTest.php +++ b/tests/Unit/Subscription/Engine/MessageProcessorTest.php @@ -9,8 +9,10 @@ use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Engine\Error; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; +use Patchlevel\EventSourcing\Subscription\Engine\ProcessingStopped; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; @@ -141,11 +143,11 @@ public function onProfileVisited(ProfileVisited $event, int $unresolved): void $error = $processor->process(1, $message, $subscription); - self::assertNotNull($error); + self::assertInstanceOf(Error::class, $error); self::assertInstanceOf(NoSuitableResolver::class, $error->throwable); } - public function testListenerCanSkipAMessage(): void + public function testListenerCanStopTheSubscription(): void { $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] class { @@ -160,7 +162,7 @@ public function onProfileVisited(ProfileVisited $event): void $eventDispatcher = new EventDispatcher(); $eventDispatcher->addListener(OnHandleMessage::class, static function (OnHandleMessage $event): void { - $event->skip = true; + $event->stop = true; }); $processor = new MessageProcessor( @@ -174,9 +176,9 @@ public function onProfileVisited(ProfileVisited $event): void $error = $processor->process(1, Message::create(new ProfileVisited(ProfileId::fromString('1'))), $subscription); - self::assertNull($error); + self::assertInstanceOf(ProcessingStopped::class, $error); self::assertFalse($subscriber->called); - self::assertSame(0, $subscription->position(), 'a skipped message does not move the position'); + self::assertSame(0, $subscription->position(), 'a stopped subscription does not move the position'); } public function testPartitionGetsItsPartitionKey(): void diff --git a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php index 0196539ec..da714589e 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php @@ -4,10 +4,15 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Engine; +use Patchlevel\EventSourcing\Attribute\BatchFlush; +use Patchlevel\EventSourcing\Attribute\Subscribe; +use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\IndexMessageLoader; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\BatchSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; @@ -17,7 +22,9 @@ use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\BatchManager; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; +use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; @@ -217,13 +224,86 @@ public function testPartitionsLoadTheirInboxesInChunks(): void self::assertSame(3000, $partitions[2]->position()); } + public function testStoppedSubscriptionDoesNotProcessTheFollowingMessages(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + public int $handled = 0; + + #[Subscribe(ProfileVisited::class)] + public function on(ProfileVisited $event): void + { + $this->handled++; + } + }; + + $result = $this->processWithStopAtTheSecondMessage($subscriber, $subscription = new Subscription('foo', status: Status::Active, position: 0)); + + self::assertSame([], $result->errors); + self::assertSame(1, $subscriber->handled, 'the message after the stopped one is not processed either'); + self::assertSame(1, $subscription->position()); + } + + public function testStoppedBatchOnlyMovesToTheLastHandledMessage(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + public int $flushed = 0; + + #[Subscribe(ProfileVisited::class)] + public function on(ProfileVisited $event): void + { + } + + #[BatchFlush] + public function flush(): void + { + $this->flushed++; + } + }; + + $result = $this->processWithStopAtTheSecondMessage($subscriber, $subscription = new Subscription('foo', status: Status::Active, position: 0)); + + self::assertSame([], $result->errors); + self::assertSame(1, $subscriber->flushed); + self::assertSame(1, $subscription->position(), 'the flush does not move the position past the stopped message'); + } + + private function processWithStopAtTheSecondMessage(object $subscriber, Subscription $subscription): ProcessedResult + { + $subscriberRepository = new MetadataSubscriberAccessorRepository([$subscriber]); + + $handled = 0; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(OnHandleMessage::class, static function (OnHandleMessage $event) use (&$handled): void { + $handled++; + + if ($handled !== 2) { + return; + } + + $event->stop = true; + }, 64); + $eventDispatcher->addSubscriber(new BatchSubscriber(new BatchManager(), $subscriberRepository)); + + $message = Message::create(new ProfileVisited(ProfileId::fromString('1'))); + + $loader = $this->createMock(MessageLoader::class); + $loader + ->method('load') + ->willReturn(new Stream([1 => $message, 2 => $message, 3 => $message])); + + $runner = $this->runner($loader, $subscriberRepository, null, $eventDispatcher); + + return $runner->process([$subscription], null); + } + private function runner( MessageLoader $loader, SubscriberAccessorRepository $subscriberRepository, SubscriptionStore|null $store = null, + EventDispatcher $eventDispatcher = new EventDispatcher(), ): SubscriptionRunner { - $eventDispatcher = new EventDispatcher(); - return new SubscriptionRunner( $loader, new SubscriptionManager($store ?? $this->createMock(SubscriptionStore::class)), From c2370ae3fccae0352c99dac863b77c57b0c0eb81 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:00:39 +0200 Subject: [PATCH 23/57] Detach every partition of a detached subscriber Only active, paused and finished partitions were detached. Booting ones stayed booting and partitions in error kept being retried although their subscriber was gone. --- .../Engine/Listener/DetachListener.php | 11 ++++++- .../Engine/Listener/DetachListenerTest.php | 32 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php index 9c2b93c5a..1e3ec085f 100644 --- a/src/Subscription/Engine/Listener/DetachListener.php +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -51,9 +51,18 @@ function (Subscription $subscription): void { $subscription->detached(); $this->subscriptionManager->update($subscription); + // every partition, also booting or failed ones, otherwise they would be retried without a subscriber $partitions = $this->subscriptionManager->find(new SubscriptionCriteria( ids: [$subscription->id()], - status: [Status::Active, Status::Paused, Status::Finished], + status: [ + Status::New, + Status::Booting, + Status::Active, + Status::Paused, + Status::Finished, + Status::Error, + Status::Failed, + ], partitions: true, )); diff --git a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php index dcd86abc2..4ca6af138 100644 --- a/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/DetachListenerTest.php @@ -12,6 +12,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\Unit\Subscription\DummySubscriptionStore; @@ -85,4 +86,35 @@ public function testDetachesPausedAndFinishedSubscriptions(): void new Subscription('finished', Subscription::DEFAULT_GROUP, RunMode::FromBeginning, Status::Detached), ); } + + public function testDetachesEveryPartitionOfTheSubscriber(): void + { + $main = new Subscription('test', status: Status::Active); + $partitions = []; + + foreach ([Status::Booting, Status::Active, Status::Paused, Status::Error, Status::Failed] as $status) { + $partition = Subscription::partition($main, $status->value, 0); + + match ($status) { + Status::Booting => $partition->booting(), + Status::Active => $partition->active(), + Status::Paused => $partition->pause(), + Status::Error => $partition->error('broken'), + default => $partition->failed('broken'), + }; + + $partitions[] = $partition; + } + + $store = new InMemorySubscriptionStore([$main, ...$partitions]); + + $listener = new DetachListener(new SubscriptionManager($store), new MetadataSubscriberAccessorRepository([])); + $listener(new OnCommand(new Run())); + + self::assertSame(Status::Detached, $store->get('test')->status()); + + foreach ($partitions as $partition) { + self::assertSame(Status::Detached, $store->get($partition->id())->status(), $partition->id()); + } + } } From 4ba4952c6b0962f2bc36703c58341e76bf9ef8af Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:01:56 +0200 Subject: [PATCH 24/57] Only remove partitions when the main subscription is removed A custom store that does not know the partitions criteria returns the main subscription itself. The listener removed it again and dispatched the removed event for it, which ended in an endless recursion. --- .../Listener/RemovePartitionsListener.php | 9 +- .../Listener/RemovePartitionsListenerTest.php | 138 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php diff --git a/src/Subscription/Engine/Listener/RemovePartitionsListener.php b/src/Subscription/Engine/Listener/RemovePartitionsListener.php index b6921e889..898dff0e1 100644 --- a/src/Subscription/Engine/Listener/RemovePartitionsListener.php +++ b/src/Subscription/Engine/Listener/RemovePartitionsListener.php @@ -7,10 +7,13 @@ use Patchlevel\EventSourcing\Subscription\Engine\Event\OnSubscriptionRemoved; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Subscription; use Psr\Log\LoggerInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use function array_filter; +use function array_values; use function count; use function sprintf; @@ -36,7 +39,11 @@ public function onSubscriptionRemoved(OnSubscriptionRemoved $event): void return; } - $partitions = $this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)); + $partitions = array_values(array_filter( + $this->subscriptionManager->find(new SubscriptionCriteria(ids: [$main->id()], partitions: true)), + // a store that does not know the partitions criteria would return the main subscription again + static fn (Subscription $subscription): bool => $subscription->isPartition(), + )); if ($partitions === []) { return; diff --git a/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php new file mode 100644 index 000000000..5cb41c714 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php @@ -0,0 +1,138 @@ +removeMain($store, $main); + + self::assertSame(['profile', 'other'], $this->ids($store->find())); + } + + /** A custom store that ignores the partitions criteria returns the main subscription as well. */ + public function testIgnoresTheMainSubscriptionFromAStoreWithoutPartitionsCriteria(): void + { + $main = new Subscription('profile', status: Status::Active); + $partition = Subscription::partition($main, 'a', 0); + + $inner = new InMemorySubscriptionStore([$main, $partition]); + $store = new class ($inner) implements SubscriptionStore { + public function __construct( + private readonly InMemorySubscriptionStore $inner, + ) { + } + + public function get(string $subscriptionId): Subscription + { + return $this->inner->get($subscriptionId); + } + + /** @return list */ + public function find(SubscriptionCriteria|null $criteria = null): array + { + return $this->inner->find(new SubscriptionCriteria(ids: $criteria?->ids)); + } + + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claim(array $ids, SubscriptionCriteria $criteria): array + { + return $this->inner->claim($ids, $criteria); + } + + public function add(Subscription ...$subscriptions): void + { + $this->inner->add(...$subscriptions); + } + + public function update(Subscription ...$subscriptions): void + { + $this->inner->update(...$subscriptions); + } + + public function remove(Subscription ...$subscriptions): void + { + $this->inner->remove(...$subscriptions); + } + + /** + * @param Closure():T $closure + * + * @return T + * + * @template T + */ + public function inLock(Closure $closure): mixed + { + return $closure(); + } + }; + + $removed = $this->removeMain($store, $main); + + self::assertSame(['profile'], $this->ids($inner->find())); + self::assertSame(['profile#a', 'profile'], $removed, 'the main subscription is not removed a second time'); + } + + /** @return list ids of the subscriptions the removed event was dispatched for */ + private function removeMain(SubscriptionStore $store, Subscription $main): array + { + $subscriptionManager = new SubscriptionManager($store); + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addSubscriber(new RemovePartitionsListener($subscriptionManager, $eventDispatcher)); + + $removed = []; + $eventDispatcher->addListener( + OnSubscriptionRemoved::class, + static function (OnSubscriptionRemoved $event) use (&$removed): void { + $removed[] = $event->subscription->id(); + }, + -1, + ); + + $eventDispatcher->dispatch(new OnSubscriptionRemoved($main)); + $subscriptionManager->flush(); + + return $removed; + } + + /** + * @param list $subscriptions + * + * @return list + */ + private function ids(array $subscriptions): array + { + return array_map(static fn (Subscription $subscription): string => $subscription->id(), $subscriptions); + } +} From 6ab608d7a516331d4cd0391cac5f9a4687ffd889 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:04:53 +0200 Subject: [PATCH 25/57] Document that booting partitions do not emit events --- docs/subscription.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/subscription.md b/docs/subscription.md index 3c7b68883..52c641ac1 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -936,6 +936,11 @@ Changing the partition method is safe: messages that were already routed stay in Only the order between the old and the new partition of the same aggregate is not guaranteed during the change. ::: +:::note +A partition stays booting until it processed the messages routed while booting, new messages it processes until then +do not emit events. Use `#[EnableEventEmittingDuringBoot]` or the run mode `FromNow` if they have to. +::: + #### Circuit breaker A failure that hits every partition, like a database that is down, would put every partition into the error status From 252801d5a6938aaed3050e51333397b560614603 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:05:23 +0200 Subject: [PATCH 26/57] Explain the message limit of partition groups --- docs/UPGRADE-4.0.md | 3 ++- docs/subscription.md | 4 +++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 6ee9444c2..6afd6205a 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -477,7 +477,8 @@ For `Run` and `Boot`, the `limit` used to cap the total number of messages acros Because there is no shared stream anymore, `limit` now caps the messages **per subscription**. One `subscription:run` pass therefore processes up to `limit × number of subscriptions` messages. The CLI default of `message-limit=100` now means "100 per subscription". -If several subscriptions are claimed together with `subscriptionsPerClaim`, the limit applies to the whole group. It also defines the +If several subscriptions are claimed together with `subscriptionsPerClaim`, the limit applies to the whole group, +for partitions claimed together it applies to every partition. It also defines the checkpoint/lock-hold granularity: a unit of at most `limit` messages commits atomically and releases the lock afterwards. diff --git a/docs/subscription.md b/docs/subscription.md index 52c641ac1..9b8c15216 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1696,6 +1696,8 @@ position, and the group is committed in one transaction. Only subscriptions whose positions are at most `limit` apart (1000 without a limit) form a group, so a subscription that is rebuilt does not hold back the ones that are up to date. Partitions are claimed in groups of 100 by default, because they only read their inbox and are often many. +The `limit` of a group of subscriptions applies to the whole group, the one of a group of partitions to every +single partition, so such a group processes up to `partitionsPerClaim × limit` messages in one transaction. ```php use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; @@ -1710,7 +1712,7 @@ Subscribers with [batching](#batching) are processed one after another within a :::warning A group holds the locks of all its subscriptions until it is committed. -Large groups make the work spread worse over several workers, and the `limit` applies to the whole group. +Large groups make the work spread worse over several workers. ::: :::note From fcee4e6430817f5efe2c3b188e9d8df5848f16f3 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:18:54 +0200 Subject: [PATCH 27/57] Read only the limit of every inbox The inbox query numbered every message of a partition with ROW_NUMBER() and filtered by the limit afterwards, so a partition with a large backlog was read completely on every run. With a backlog of one million messages one call took 2.7s on MySQL, 1.9s on MariaDB and 148ms on PostgreSQL. PostgreSQL now uses a lateral join with a limit per partition, the other databases a union of one limited query per partition, MySQL does not limit a lateral join early enough and MariaDB and SQLite do not support it. One call now takes 1 to 3ms, no matter how large the backlog is. --- .../Store/DoctrineSubscriptionStore.php | 80 ++++++++++++++----- .../DoctrineSubscriptionStoreTest.php | 17 ++++ 2 files changed, 77 insertions(+), 20 deletions(-) diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index ad037a95a..08c484926 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -44,6 +44,7 @@ use function json_decode; use function json_encode; use function serialize; +use function sort; use function sprintf; use function unserialize; @@ -86,6 +87,9 @@ final class DoctrineSubscriptionStore implements PartitionInboxStore, DoctrineSc /** How many inbox rows or conditions go into one statement, far below the parameter limits of the databases. */ private const INBOX_CHUNK = 500; + /** How many partitions are read with one union, SQLite allows at most 500 parts. */ + private const INBOX_UNION_CHUNK = 100; + /** How many subscriptions go into one insert or update, 100 rows stay below 3000 parameters. */ private const ROW_CHUNK = 100; @@ -634,48 +638,84 @@ public function addToInbox(array $indices): void public function inbox(array $afterIndex, int $limit): array { $inbox = []; + $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform; - foreach (array_chunk($afterIndex, self::INBOX_CHUNK, true) as $chunk) { - $conditions = []; + foreach (array_chunk($afterIndex, self::INBOX_UNION_CHUNK, true) as $chunk) { $parameters = []; $types = []; foreach ($chunk as $subscriptionId => $index) { - $conditions[] = '(subscription_id = ? AND message_index > ?)'; $parameters[] = (string)$subscriptionId; $parameters[] = $index; $types[] = ParameterType::STRING; $types[] = ParameterType::INTEGER; } - // sqlite compares the untyped row number with a string parameter as text, so the types are explicit - $parameters[] = $limit; - $types[] = ParameterType::INTEGER; + // every partition reads only up to its limit, no matter how many messages wait in its inbox + $sql = $isPostgres ? $this->lateralInboxQuery(count($chunk), $limit) : $this->unionInboxQuery(count($chunk), $limit); - // the row number limits the messages per subscription, so one query serves a whole group of partitions /** @var list $rows */ - $rows = $this->connection->fetchAllNumeric( - sprintf( - 'SELECT subscription_id, message_index FROM ( - SELECT subscription_id, message_index, ROW_NUMBER() OVER (PARTITION BY subscription_id ORDER BY message_index) AS inbox_position - FROM %s - WHERE %s - ) inbox WHERE inbox_position <= ? ORDER BY subscription_id, message_index', - $this->inboxTableName, - implode(' OR ', $conditions), - ), - $parameters, - $types, - ); + $rows = $this->connection->fetchAllNumeric($sql, $parameters, $types); foreach ($rows as [$subscriptionId, $messageIndex]) { $inbox[(string)$subscriptionId][] = (int)$messageIndex; } } + // the order of the rows across the partitions is not guaranteed + foreach ($inbox as &$indices) { + sort($indices); + } + + unset($indices); + return $inbox; } + /** + * PostgreSQL plans a lateral join once, a union of many queries would cost more than reading the inbox. + * + * @param positive-int $partitions + * @param positive-int $limit + */ + private function lateralInboxQuery(int $partitions, int $limit): string + { + return sprintf( + 'SELECT waiting.id, inbox.message_index FROM (VALUES %s) AS waiting(id, after_index) + CROSS JOIN LATERAL ( + SELECT message_index FROM %s + WHERE subscription_id = waiting.id AND message_index > waiting.after_index + ORDER BY message_index LIMIT %d + ) inbox', + implode(', ', array_fill(0, $partitions, '(CAST(? AS VARCHAR), CAST(? AS INTEGER))')), + $this->inboxTableName, + $limit, + ); + } + + /** + * MySQL and MariaDB do not limit a lateral join early enough or do not support it, neither does SQLite. + * + * @param positive-int $partitions + * @param positive-int $limit + */ + private function unionInboxQuery(int $partitions, int $limit): string + { + $selects = []; + + for ($i = 0; $i < $partitions; $i++) { + // the derived table allows the limit in every part of the union + $selects[] = sprintf( + 'SELECT subscription_id, message_index FROM (SELECT subscription_id, message_index FROM %s WHERE subscription_id = ? AND message_index > ? ORDER BY message_index LIMIT %d) inbox_%d', + $this->inboxTableName, + $limit, + $i, + ); + } + + return implode(' UNION ALL ', $selects); + } + /** @param array $toIndex */ private function removeFromInbox(array $toIndex): void { diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 0d391cbc9..5a50952e9 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1162,6 +1162,23 @@ public function testInbox(): void ); } + public function testInboxOfManyPartitions(): void + { + $store = $this->createStore(); + + $afterIndex = []; + $expected = []; + + for ($i = 0; $i < 150; $i++) { + $id = Subscription::partitionId('profile', 'k' . $i); + $store->addToInbox([$id => [1000 + $i, $i + 1]]); + $afterIndex[$id] = 0; + $expected[$id] = [$i + 1]; + } + + self::assertEquals($expected, $store->inbox($afterIndex, 1), 'more partitions than one query reads'); + } + public function testPendingPartitionsOnly(): void { $store = $this->createStore(); From 009ab9fab36f567a7554f1ce06eca7450922bdeb Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:19:03 +0200 Subject: [PATCH 28/57] Document how batching works with partitions --- docs/subscription.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/docs/subscription.md b/docs/subscription.md index 9b8c15216..01b054331 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -941,6 +941,11 @@ A partition stays booting until it processed the messages routed while booting, do not emit events. Use `#[EnableEventEmittingDuringBoot]` or the run mode `FromNow` if they have to. ::: +:::note +With [batching](#batching), every partition has its own batch and the partitions are processed one after another. +Many small partitions therefore mean many small batches. +::: + #### Circuit breaker A failure that hits every partition, like a database that is down, would put every partition into the error status From 88f23eb5cee3289fb0ade8fcd17dc1b85a0e06f5 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:26:27 +0200 Subject: [PATCH 29/57] Emit the events of all partitions into the stream of their subscriber Every partition emitted into its own subscription_{subscriber}#{key} stream. Readers of the subscriber stream missed these events, removing a subscriber deleted one stream per partition, and with a long partition key the stream name did not fit into the stream column anymore. The emitted stream belongs to the subscriber like setup and cleanup, so partitions now share it and it is removed once with the main subscription. --- docs/subscription.md | 1 + .../RemoveSubscriptionStreamListener.php | 5 +++++ .../ArgumentResolver/EventEmitterResolver.php | 3 ++- .../RemoveSubscriptionStreamListenerTest.php | 9 +++++++++ .../EventEmitterResolverTest.php | 19 +++++++++++++++++++ 5 files changed, 36 insertions(+), 1 deletion(-) diff --git a/docs/subscription.md b/docs/subscription.md index 01b054331..d4d981c79 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -349,6 +349,7 @@ projection that, after it has been updated, wants a notification subscription to If you type-hint an `EventEmitter` argument in a subscribe method, it is injected automatically. By default the emitted events are written into a dedicated stream named `subscription_`. +The [partitions](#partitioning) of a subscriber share the stream of their subscriber. ```php use Patchlevel\EventSourcing\Attribute\Subscribe; diff --git a/src/Subscription/Engine/Listener/RemoveSubscriptionStreamListener.php b/src/Subscription/Engine/Listener/RemoveSubscriptionStreamListener.php index 161b8faea..e5d7e9979 100644 --- a/src/Subscription/Engine/Listener/RemoveSubscriptionStreamListener.php +++ b/src/Subscription/Engine/Listener/RemoveSubscriptionStreamListener.php @@ -23,6 +23,11 @@ public function __construct( public function __invoke(OnSubscriptionRemoved $event): void { + // partitions write into the stream of their subscriber, it is removed with the main subscription + if ($event->subscription->isPartition()) { + return; + } + $streamName = 'subscription_' . $event->subscription->id(); $this->store->remove( diff --git a/src/Subscription/Subscriber/ArgumentResolver/EventEmitterResolver.php b/src/Subscription/Subscriber/ArgumentResolver/EventEmitterResolver.php index 018fdd341..ba89f2852 100644 --- a/src/Subscription/Subscriber/ArgumentResolver/EventEmitterResolver.php +++ b/src/Subscription/Subscriber/ArgumentResolver/EventEmitterResolver.php @@ -32,9 +32,10 @@ public function resolve(ArgumentMetadata $argument, ArgumentResolverContext $con return new NoopEventEmitter(); } + // the partitions of a subscriber share its stream return new StoreEventEmitter( $this->store, - 'subscription_' . $context->subscription->id(), + 'subscription_' . $context->subscription->subscriberId(), ); } diff --git a/tests/Unit/Subscription/Engine/Listener/RemoveSubscriptionStreamListenerTest.php b/tests/Unit/Subscription/Engine/Listener/RemoveSubscriptionStreamListenerTest.php index a1f924328..ab3bc7e28 100644 --- a/tests/Unit/Subscription/Engine/Listener/RemoveSubscriptionStreamListenerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RemoveSubscriptionStreamListenerTest.php @@ -26,4 +26,13 @@ public function testRemovesSubscriptionStream(): void $listener = new RemoveSubscriptionStreamListener($store); $listener(new OnSubscriptionRemoved(new Subscription('foo'))); } + + public function testKeepsTheStreamWhenAPartitionIsRemoved(): void + { + $store = $this->createMock(Store::class); + $store->expects($this->never())->method('remove'); + + $listener = new RemoveSubscriptionStreamListener($store); + $listener(new OnSubscriptionRemoved(Subscription::partition(new Subscription('foo'), 'tenant-a', 0))); + } } diff --git a/tests/Unit/Subscription/Subscriber/ArgumentResolver/EventEmitterResolverTest.php b/tests/Unit/Subscription/Subscriber/ArgumentResolver/EventEmitterResolverTest.php index 50bd8504c..791197c62 100644 --- a/tests/Unit/Subscription/Subscriber/ArgumentResolver/EventEmitterResolverTest.php +++ b/tests/Unit/Subscription/Subscriber/ArgumentResolver/EventEmitterResolverTest.php @@ -71,6 +71,25 @@ public function testResolvedEmitterEmitsToSubscriptionStream(): void self::assertSame(['subscription_foo'], $store->streams()); } + public function testPartitionEmitsToTheStreamOfItsSubscriber(): void + { + $store = new InMemoryStore(); + $resolver = new EventEmitterResolver($store); + + $emitter = $resolver->resolve( + new ArgumentMetadata('emitter', Type::object(EventEmitter::class)), + new ArgumentResolverContext( + new Message(new stdClass()), + Subscription::partition(new Subscription('foo', status: Status::Active), 'tenant-a', 0), + new SubscriberMetadata('foo'), + ), + ); + + $emitter->emit([new stdClass()]); + + self::assertSame(['subscription_foo'], $store->streams()); + } + public function testResolveDuringBootReturnsNoopByDefault(): void { $resolver = new EventEmitterResolver($this->createMock(Store::class)); From c2c621b18982928e4f039519ff0123d4115e49ab Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:31:37 +0200 Subject: [PATCH 30/57] Pause and reactivate partitions in groups Pausing or reactivating a partitioned subscriber claimed every partition in its own transaction. Partitions are now claimed in groups of 100, other subscriptions are still handled one by one. --- .../Engine/Handler/PauseHandler.php | 31 +++++--- .../Engine/Handler/ReactivateHandler.php | 78 +++++++++++-------- 2 files changed, 66 insertions(+), 43 deletions(-) diff --git a/src/Subscription/Engine/Handler/PauseHandler.php b/src/Subscription/Engine/Handler/PauseHandler.php index 9a714e6db..867c399c4 100644 --- a/src/Subscription/Engine/Handler/PauseHandler.php +++ b/src/Subscription/Engine/Handler/PauseHandler.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Pause; use Patchlevel\EventSourcing\Subscription\Engine\Error; @@ -15,6 +16,7 @@ use Psr\Log\LoggerInterface; use Throwable; +use function array_map; use function sprintf; /** @@ -24,6 +26,8 @@ */ final class PauseHandler implements Handler { + private const PARTITIONS_PER_CLAIM = 100; + public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly LoggerInterface|null $logger = null, @@ -32,7 +36,8 @@ public function __construct( public function __invoke(Command $command): Result { - $results = $this->subscriptionManager->forEachClaimed( + // partitions are claimed in groups, a subscriber can have many of them + $results = $this->subscriptionManager->forEachClaimedGroup( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, @@ -42,20 +47,24 @@ public function __invoke(Command $command): Result Status::Error, ], ), - function (Subscription $subscription): Result { - $subscription->pause(); - $this->subscriptionManager->update($subscription); + new ClaimGroups(partitions: self::PARTITIONS_PER_CLAIM), + function (array $subscriptions): Result { + foreach ($subscriptions as $subscription) { + $subscription->pause(); + $this->subscriptionManager->update($subscription); - $this->logger?->info(sprintf( - 'Subscription Engine: Subscription "%s" is paused.', - $subscription->id(), - )); + $this->logger?->info(sprintf( + 'Subscription Engine: Subscription "%s" is paused.', + $subscription->id(), + )); + } return new Result(); }, - static fn (Subscription $subscription, Throwable $e): Result => new Result( - [new Error($subscription->id(), $e->getMessage(), $e)], - ), + static fn (array $subscriptions, Throwable $e): Result => new Result(array_map( + static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), + $subscriptions, + )), ); return Result::merge($results); diff --git a/src/Subscription/Engine/Handler/ReactivateHandler.php b/src/Subscription/Engine/Handler/ReactivateHandler.php index 452c82fc6..87ae0dc98 100644 --- a/src/Subscription/Engine/Handler/ReactivateHandler.php +++ b/src/Subscription/Engine/Handler/ReactivateHandler.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Reactivate; use Patchlevel\EventSourcing\Subscription\Engine\Error; @@ -16,6 +17,7 @@ use Psr\Log\LoggerInterface; use Throwable; +use function array_map; use function sprintf; /** @@ -25,6 +27,8 @@ */ final class ReactivateHandler implements Handler { + private const PARTITIONS_PER_CLAIM = 100; + public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, @@ -34,7 +38,8 @@ public function __construct( public function __invoke(Command $command): Result { - $results = $this->subscriptionManager->forEachClaimed( + // partitions are claimed in groups, a subscriber can have many of them + $results = $this->subscriptionManager->forEachClaimedGroup( new SubscriptionCriteria( ids: $command->ids, groups: $command->groups, @@ -46,44 +51,53 @@ public function __invoke(Command $command): Result Status::Finished, ], ), - function (Subscription $subscription): Result { - $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); - - if (!$subscriber) { - $this->logger?->debug( - sprintf( - 'Subscription Engine: Subscriber for "%s" not found, skipped.', - $subscription->id(), - ), - ); - - return new Result(); + new ClaimGroups(partitions: self::PARTITIONS_PER_CLAIM), + function (array $subscriptions): Result { + foreach ($subscriptions as $subscription) { + $this->reactivate($subscription); } - $error = $subscription->subscriptionError(); + return new Result(); + }, + static fn (array $subscriptions, Throwable $e): Result => new Result(array_map( + static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), + $subscriptions, + )), + ); - if ($error) { - $subscription->doRetry(); - $subscription->resetRetry(); - } else { - $subscription->active(); - } + return Result::merge($results); + } - $this->subscriptionManager->update($subscription); + private function reactivate(Subscription $subscription): void + { + $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); - $this->logger?->info(sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', - $subscriber::class, + if (!$subscriber) { + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber for "%s" not found, skipped.', $subscription->id(), - )); + ), + ); - return new Result(); - }, - static fn (Subscription $subscription, Throwable $e): Result => new Result( - [new Error($subscription->id(), $e->getMessage(), $e)], - ), - ); + return; + } - return Result::merge($results); + $error = $subscription->subscriptionError(); + + if ($error) { + $subscription->doRetry(); + $subscription->resetRetry(); + } else { + $subscription->active(); + } + + $this->subscriptionManager->update($subscription); + + $this->logger?->info(sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" is reactivated.', + $subscriber::class, + $subscription->id(), + )); } } From c5c403fae4f7ee23b1b4a34bfe8b40dbf1fc2f41 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:31:37 +0200 Subject: [PATCH 31/57] Remove subscriptions with one statement per chunk Removing a partitioned subscriber deleted every partition and its inbox with two statements each. --- .../Store/DoctrineSubscriptionStore.php | 17 ++++++++++++++--- .../Store/DoctrineSubscriptionStoreTest.php | 17 ++++++++++------- 2 files changed, 24 insertions(+), 10 deletions(-) diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 08c484926..59d869465 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -386,9 +386,20 @@ public function update(Subscription ...$subscriptions): void /** The inbox of a removed partition is removed as well. */ public function remove(Subscription ...$subscriptions): void { - foreach ($subscriptions as $subscription) { - $this->connection->delete($this->tableName, ['id' => $subscription->id()]); - $this->connection->delete($this->inboxTableName, ['subscription_id' => $subscription->id()]); + $ids = array_map(static fn (Subscription $subscription): string => $subscription->id(), array_values($subscriptions)); + + // removing a subscriber removes all its partitions, one statement per chunk instead of per partition + foreach (array_chunk($ids, self::INBOX_CHUNK) as $chunk) { + $this->connection->executeStatement( + sprintf('DELETE FROM %s WHERE id IN (?)', $this->tableName), + [$chunk], + [ArrayParameterType::STRING], + ); + $this->connection->executeStatement( + sprintf('DELETE FROM %s WHERE subscription_id IN (?)', $this->inboxTableName), + [$chunk], + [ArrayParameterType::STRING], + ); } } diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index f1851eb28..28cc6a749 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Store; use DateTimeImmutable; +use Doctrine\DBAL\ArrayParameterType; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Driver\PDO\Exception; use Doctrine\DBAL\Exception\DeadlockException; @@ -494,19 +495,21 @@ public function testRemove(): void $connection = $this->createMock(Connection::class); $connection ->expects($this->exactly(2)) - ->method('delete') - ->willReturnCallback(static function (string $table, array $criteria): int { - self::assertContains([$table, $criteria], [ - ['subscriptions', ['id' => 'foo']], - ['subscriptions_inbox', ['subscription_id' => 'foo']], + ->method('executeStatement') + ->willReturnCallback(static function (string $sql, array $parameters, array $types): int { + self::assertContains($sql, [ + 'DELETE FROM subscriptions WHERE id IN (?)', + 'DELETE FROM subscriptions_inbox WHERE subscription_id IN (?)', ]); + self::assertSame([['foo', 'bar']], $parameters); + self::assertSame([ArrayParameterType::STRING], $types); - return 1; + return 2; }); $store = new DoctrineSubscriptionStore($connection); - $store->remove(new Subscription('foo')); + $store->remove(new Subscription('foo'), new Subscription('bar')); } public function testInLock(): void From cca3d9335e29f5b3bdb9f03847567c394008e3ed Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:31:43 +0200 Subject: [PATCH 32/57] Correct the commit granularity of partition groups in the upgrade guide --- docs/UPGRADE-4.0.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 6afd6205a..18978ec22 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -477,10 +477,10 @@ For `Run` and `Boot`, the `limit` used to cap the total number of messages acros Because there is no shared stream anymore, `limit` now caps the messages **per subscription**. One `subscription:run` pass therefore processes up to `limit × number of subscriptions` messages. The CLI default of `message-limit=100` now means "100 per subscription". -If several subscriptions are claimed together with `subscriptionsPerClaim`, the limit applies to the whole group, -for partitions claimed together it applies to every partition. It also defines the -checkpoint/lock-hold granularity: a unit of at most `limit` messages commits atomically and releases -the lock afterwards. +It also defines the checkpoint/lock-hold granularity: a unit of at most `limit` messages commits atomically and +releases the lock afterwards. If several subscriptions are claimed together with `subscriptionsPerClaim`, the limit +applies to the whole group. Partitions claimed together with `partitionsPerClaim` each process up to `limit` +messages, so such a group commits up to `partitionsPerClaim × limit` messages at once. #### Error isolation From 9eaab6ee4d94900f03e01be9b9333347bf4916a9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:40:12 +0200 Subject: [PATCH 33/57] Remove idle partitions Partitions were kept forever, with one per aggregate the subscription table grew with every aggregate. With #[Partition(removeIdleAfter: seconds)] the main subscription removes active partitions with an empty inbox that were not saved for that long. It does so in its own run before it routes, while it holds its lock, so it never routes into a partition that is removed at the same time. A removed partition is created again with the next message of its key. Partitions held by another worker, in error or paused are kept. By default nothing is removed. --- docs/UPGRADE-4.0.md | 4 +- docs/subscription.md | 28 ++++++++++ src/Attribute/Partition.php | 7 +++ .../AttributeSubscriberMetadataFactory.php | 4 +- src/Metadata/Subscriber/PartitionMetadata.php | 6 +- .../Engine/SubscriptionManager.php | 9 +++ .../Engine/SubscriptionRunner.php | 39 +++++++++++++ .../Store/DoctrineSubscriptionStore.php | 53 +++++++++++++++++- .../Store/InMemorySubscriptionStore.php | 32 +++++++++++ .../Store/PartitionInboxStore.php | 12 ++++ .../DoctrineSubscriptionStoreTest.php | 30 ++++++++++ .../PartitionedSubscriptionTest.php | 52 ++++++++++++++++++ .../EphemeralPartitionProjection.php | 55 +++++++++++++++++++ .../Subscription/DummySubscriptionStore.php | 9 +++ .../Store/DoctrineSubscriptionStoreTest.php | 2 +- 15 files changed, 336 insertions(+), 6 deletions(-) create mode 100644 tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 18978ec22..535215d82 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -502,7 +502,7 @@ ALTER TABLE subscriptions ADD subscriber_id VARCHAR(255) DEFAULT NULL; ALTER TABLE subscriptions ADD partition_key VARCHAR(255) DEFAULT NULL; UPDATE subscriptions SET subscriber_id = id; ALTER TABLE subscriptions ALTER COLUMN subscriber_id SET NOT NULL; -CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id); +CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, last_saved_at); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` On MySQL and MariaDB the ids and the partition key are compared binary, so that partition keys that only differ in @@ -514,7 +514,7 @@ ALTER TABLE subscriptions ADD partition_key VARCHAR(255) CHARACTER SET utf8mb4 C UPDATE subscriptions SET subscriber_id = id; ALTER TABLE subscriptions MODIFY subscriber_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; ALTER TABLE subscriptions MODIFY id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; -CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id); +CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, last_saved_at); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` Adjust the table names if you configured your own. diff --git a/docs/subscription.md b/docs/subscription.md index d4d981c79..c576349a3 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -947,6 +947,34 @@ With [batching](#batching), every partition has its own batch and the partitions Many small partitions therefore mean many small batches. ::: +#### Removing idle partitions + +By default a partition is kept forever, also after it processed its inbox. +With many keys, like one per aggregate, set `removeIdleAfter` to the seconds after which an idle partition is removed. +A partition is idle when it is active, its inbox is empty and it was not saved for that long. +The next message of its key creates it again, so nothing is lost. + +```php +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; + +#[Projector('profile_1')] +final class ProfileProjector +{ + #[Partition(removeIdleAfter: 3600)] + public function partition(Message $message): string + { + return $message->header(StreamNameHeader::class)->streamName; + } + + // ... +} +``` +The main subscription removes the idle partitions of its subscriber in its run, at most 1000 at a time. +Partitions in error or paused ones are kept. + #### Circuit breaker A failure that hits every partition, like a database that is down, would put every partition into the error status diff --git a/src/Attribute/Partition.php b/src/Attribute/Partition.php index 8f58afdab..2673cb52b 100644 --- a/src/Attribute/Partition.php +++ b/src/Attribute/Partition.php @@ -21,6 +21,13 @@ public function __construct( * @var positive-int */ public readonly int $maxConsecutiveFailures = 5, + /** + * Partitions are kept forever by default. With a number of seconds, a partition that processed its inbox and + * did nothing for that long is removed. It is created again with the next message of its key. + * + * @var positive-int|null + */ + public readonly int|null $removeIdleAfter = null, ) { } } diff --git a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php index bc2188c2e..0e286e132 100644 --- a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php @@ -184,9 +184,11 @@ public function metadata(string $subscriber): SubscriberMetadata throw InvalidPartitionMethod::invalidSignature($subscriber, $method->getName()); } + $partition = $partitionAttributes[0]->newInstance(); $partitionMethod = new PartitionMetadata( $method->getName(), - $partitionAttributes[0]->newInstance()->maxConsecutiveFailures, + $partition->maxConsecutiveFailures, + $partition->removeIdleAfter, ); } diff --git a/src/Metadata/Subscriber/PartitionMetadata.php b/src/Metadata/Subscriber/PartitionMetadata.php index 23477cc79..7d2fb8b27 100644 --- a/src/Metadata/Subscriber/PartitionMetadata.php +++ b/src/Metadata/Subscriber/PartitionMetadata.php @@ -6,10 +6,14 @@ final class PartitionMetadata { - /** @param positive-int $maxConsecutiveFailures */ + /** + * @param positive-int $maxConsecutiveFailures + * @param positive-int|null $removeIdleAfter seconds + */ public function __construct( public readonly string $method, public readonly int $maxConsecutiveFailures = 5, + public readonly int|null $removeIdleAfter = null, ) { } } diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 2c0d6b53c..772d78ed4 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -344,6 +344,15 @@ public function inbox(array $afterIndex, int $limit): array return $this->inboxStore()->inbox($afterIndex, $limit); } + /** + * @param positive-int $idleSeconds + * @param positive-int $limit + */ + public function removeIdlePartitions(string $subscriberId, int $idleSeconds, int $limit): int + { + return $this->inboxStore()->removeIdlePartitions($subscriberId, $idleSeconds, $limit); + } + public function supportsPartitions(): bool { return $this->subscriptionStore instanceof PartitionInboxStore; diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 09a60cbeb..0b282adf8 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -34,6 +34,9 @@ final class SubscriptionRunner /** How many messages of its inbox a partition processes at most in one run without a limit. */ private const INBOX_LIMIT = 1000; + /** How many idle partitions a main subscription removes at most in one run. */ + private const IDLE_PARTITIONS_LIMIT = 1000; + /** How many indices are loaded with one query, far below the parameter limits of the databases. */ private const INDEX_CHUNK = 1000; @@ -87,6 +90,7 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $inboxes = $this->loadInboxes($members, $inboxLimit); $working = $this->workingMembers($members, $inboxes); $router = new PartitionRouter($this->subscriptionManager, $this->logger); + $this->removeIdlePartitions($working); /** @var list $errors */ $errors = []; @@ -218,6 +222,41 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals ); } + /** + * The main subscription removes the idle partitions of its subscriber before it routes, while it holds its lock. + * So it never routes into a partition that is removed at the same time, a removed one is created again. + */ + private function removeIdlePartitions(SubscriptionCollection $working): void + { + foreach ($working as $member) { + if ($member->isPartition()) { + continue; + } + + $idleSeconds = $this->subscriberRepository->get($member->subscriberId())?->metadata()->partition?->removeIdleAfter; + + if ($idleSeconds === null) { + continue; + } + + $removed = $this->subscriptionManager->removeIdlePartitions( + $member->subscriberId(), + $idleSeconds, + self::IDLE_PARTITIONS_LIMIT, + ); + + if ($removed === 0) { + continue; + } + + $this->logger?->info(sprintf( + 'Subscription Engine: %d idle partitions of subscriber "%s" removed.', + $removed, + $member->subscriberId(), + )); + } + } + /** @return list */ private function processed(Subscription $subscription, int $lastIndex): array { diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 59d869465..b84b1fd14 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -727,6 +727,56 @@ private function unionInboxQuery(int $partitions, int $limit): string return implode(' UNION ALL ', $selects); } + /** + * @param positive-int $idleSeconds + * @param positive-int $limit + */ + public function removeIdlePartitions(string $subscriberId, int $idleSeconds, int $limit): int + { + $qb = $this->connection->createQueryBuilder() + ->select('id') + ->from($this->tableName) + ->where('subscriber_id = :subscriber_id') + ->andWhere('partition_key IS NOT NULL') + ->andWhere('status = :status') + ->andWhere('last_saved_at < :idle_since') + ->andWhere(sprintf( + 'NOT EXISTS (SELECT 1 FROM %s WHERE %s.subscription_id = %s.id)', + $this->inboxTableName, + $this->inboxTableName, + $this->tableName, + )) + ->setParameter('subscriber_id', $subscriberId) + ->setParameter('status', Status::Active->value) + ->setParameter( + 'idle_since', + $this->clock->now()->modify(sprintf('-%d seconds', $idleSeconds)), + Types::DATETIME_IMMUTABLE, + ) + ->orderBy('id') + ->setMaxResults($limit); + + // a partition a worker is processing right now is not idle + if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + $qb->forUpdate(ConflictResolutionMode::SKIP_LOCKED); + } + + /** @var list $ids */ + $ids = $qb->fetchFirstColumn(); + + if ($ids === []) { + return 0; + } + + $this->connection->executeStatement( + sprintf('DELETE FROM %s WHERE id IN (?)', $this->tableName), + [$ids], + [ArrayParameterType::STRING], + ); + + return count($ids); + } + /** @param array $toIndex */ private function removeFromInbox(array $toIndex): void { @@ -828,7 +878,8 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); - $table->addIndex(['subscriber_id']); + // also finds the idle partitions of a subscriber + $table->addIndex(['subscriber_id', 'last_saved_at']); $inbox = $schema->createTable($this->inboxTableName); diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index ed7a6e0c7..b1ef234b2 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -16,6 +16,7 @@ use function array_values; use function in_array; use function sort; +use function sprintf; final class InMemorySubscriptionStore implements PartitionInboxStore { @@ -220,6 +221,37 @@ public function inbox(array $afterIndex, int $limit): array return $inbox; } + /** + * @param positive-int $idleSeconds + * @param positive-int $limit + */ + public function removeIdlePartitions(string $subscriberId, int $idleSeconds, int $limit): int + { + $idleSince = $this->clock->now()->modify(sprintf('-%d seconds', $idleSeconds)); + $removed = 0; + + foreach ($this->subscriptions as $id => $subscription) { + if ($removed >= $limit) { + break; + } + + if ( + !$subscription->isPartition() + || $subscription->subscriberId() !== $subscriberId + || !$subscription->isActive() + || ($this->inbox[$id] ?? []) !== [] + || ($subscription->lastSavedAt() ?? $idleSince) >= $idleSince + ) { + continue; + } + + unset($this->subscriptions[$id], $this->inbox[$id]); + $removed++; + } + + return $removed; + } + public function clear(): void { $this->subscriptions = []; diff --git a/src/Subscription/Store/PartitionInboxStore.php b/src/Subscription/Store/PartitionInboxStore.php index cd995ee31..756b144fc 100644 --- a/src/Subscription/Store/PartitionInboxStore.php +++ b/src/Subscription/Store/PartitionInboxStore.php @@ -26,4 +26,16 @@ public function addToInbox(array $indices): void; * @return array> */ public function inbox(array $afterIndex, int $limit): array; + + /** + * Removes active partitions of the subscriber with an empty inbox that were not saved for the given seconds. + * Partitions that are held by another worker are left out. Only call it while holding the lock of the main + * subscription, otherwise it could remove a partition the main subscription routes into at the same time. + * + * @param positive-int $idleSeconds + * @param positive-int $limit + * + * @return int how many partitions were removed + */ + public function removeIdlePartitions(string $subscriberId, int $idleSeconds, int $limit): int; } diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 5a50952e9..ffe9ad6b8 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1229,6 +1229,36 @@ private function createStore(): DoctrineSubscriptionStore return $store; } + public function testRemoveIdlePartitions(): void + { + $clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); + $store = new DoctrineSubscriptionStore($this->connection, $clock); + (new DoctrineSchemaDirector($this->connection, $store))->create(); + + $main = new Subscription('profile', status: Status::Active, position: 10); + $other = new Subscription('other', status: Status::Active, position: 10); + $idle = Subscription::partition($main, 'idle', 1); + $withInbox = Subscription::partition($main, 'inbox', 1); + $failing = Subscription::partition($main, 'error', 1); + $failing->error('broken'); + $otherIdle = Subscription::partition($other, 'idle', 1); + $store->add($main, $other, $idle, $withInbox, $failing, $otherIdle); + $store->addToInbox([$withInbox->id() => [5]]); + + $clock->sleep(30); + $recent = Subscription::partition($main, 'recent', 1); + $store->add($recent); + + $clock->sleep(40); + + self::assertSame(1, $store->removeIdlePartitions('profile', 60, 10)); + self::assertSame( + ['other', 'other#idle', 'profile', 'profile#error', 'profile#inbox', 'profile#recent'], + array_map(static fn (Subscription $subscription): string => $subscription->id(), $store->find()), + ); + self::assertSame(0, $store->removeIdlePartitions('profile', 60, 10), 'nothing left to remove'); + } + public function testClaimAll(): void { $store = $this->createStore(); diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index bbea1c091..272df938f 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -37,6 +37,7 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\EphemeralPartitionProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedBatchProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedTransactionalBatchProjection; @@ -466,6 +467,57 @@ public function testKeysThatOnlyDifferInCaseAreDifferentPartitions(bool $sameRun self::assertSame(['tenant-ACME', 'tenant-acme'], $keys); } + public function testIdlePartitionsAreRemovedAndCreatedAgain(): void + { + $projection = new EphemeralPartitionProjection(); + $projection->failingNames = ['broken']; + $engine = $this->engine($projection); + $subscriberId = EphemeralPartitionProjection::SUBSCRIBER_ID; + + $engine->execute(new Setup(skipBooting: true)); + + [$idleId, $busyId, $brokenId] = $this->createProfiles(3, ['idle', 'busy', 'broken']); + $this->runUntilFinished($engine); + + self::assertCount(3, $this->partitions($subscriberId)); + + $this->clock->sleep(40); + $this->changeName(ProfileId::fromString($busyId), 'busy-2'); + $this->runUntilFinished($engine); + + self::assertCount(3, $this->partitions($subscriberId), 'nothing is idle long enough yet'); + + $this->clock->sleep(30); + $this->runUntilFinished($engine); + + $expected = [ + Subscription::partitionId($subscriberId, 'profile-' . $brokenId), + Subscription::partitionId($subscriberId, 'profile-' . $busyId), + ]; + sort($expected); + + self::assertSame( + $expected, + $this->sortedPartitionIds($subscriberId), + 'the idle partition is removed, a busy one and one in error stay', + ); + + $this->changeName(ProfileId::fromString($idleId), 'idle-2'); + $this->runUntilFinished($engine); + + self::assertSame(['idle', 'idle-2'], $projection->processed[$idleId], 'the next message creates it again'); + self::assertCount(3, $this->partitions($subscriberId)); + } + + /** @return list */ + private function sortedPartitionIds(string $subscriberId): array + { + $ids = array_keys($this->partitions($subscriberId)); + sort($ids); + + return $ids; + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { diff --git a/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php b/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php new file mode 100644 index 000000000..bebd4e85a --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php @@ -0,0 +1,55 @@ +> profile id => processed names */ + public array $processed = []; + + /** @var list */ + public array $failingNames = []; + + #[Partition(removeIdleAfter: 60)] + public function partition(Message $message): string + { + return $message->header(StreamNameHeader::class)->streamName; + } + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + private function record(string $profileId, string $name): void + { + if (in_array($name, $this->failingNames, true)) { + throw new RuntimeException('Forced failure for ' . $name); + } + + $this->processed[$profileId][] = $name; + } +} diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 009dd1fa5..5a74a806f 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -121,6 +121,15 @@ public function inbox(array $afterIndex, int $limit): array return $this->parentStore->inbox($afterIndex, $limit); } + /** + * @param positive-int $idleSeconds + * @param positive-int $limit + */ + public function removeIdlePartitions(string $subscriberId, int $idleSeconds, int $limit): int + { + return $this->parentStore->removeIdlePartitions($subscriberId, $idleSeconds, $limit); + } + public function reset(): void { $this->addedSubscriptions = []; diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index 28cc6a749..2e886b5ad 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -611,7 +611,7 @@ public function testConfigureSchema(): void $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); - $table->addIndex(['subscriber_id']); + $table->addIndex(['subscriber_id', 'last_saved_at']); $inbox = $expectedSchema->createTable('subscriptions_inbox'); $inbox->addColumn('subscription_id', Types::STRING) From 4c096bea86c2f87584ecd3efe8c5ae2a6d5fa91d Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 12:52:16 +0200 Subject: [PATCH 34/57] Finish the partitions of a run once subscriber A partition that processed its inbox before its main subscription finished was never claimed again and stayed active. Once the main subscription finished, run now processes its active partitions one last time, so they finish as well. --- .../Engine/Handler/RunHandler.php | 34 ++++++++++++++++++- .../PartitionedSubscriptionTest.php | 22 ++++++++++++ .../Subscriber/OncePartitionedProjection.php | 33 ++++++++++++++++++ 3 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index 8205ec052..e75243fc5 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -76,7 +76,39 @@ public function __invoke(Command $command): ProcessedResult ), ]; - return ProcessedResult::merge($results); + return ProcessedResult::merge([...$results, ...$this->finishPartitions($command, $claimGroups)]); + } + + /** + * A partition of a subscriber with run mode once can process its inbox before its main subscription finished. + * Once the main subscription finished, its partitions that are still active are processed one last time, so that + * they finish as well, even if their inbox is already empty. + * + * @return list + */ + private function finishPartitions(Run $command, ClaimGroups $claimGroups): array + { + $subscriberIds = []; + + $mains = $this->subscriptionManager->find(new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Finished], + partitions: false, + )); + + foreach ($mains as $main) { + $subscriberIds[] = $main->id(); + } + + if ($subscriberIds === []) { + return []; + } + + $criteria = new SubscriptionCriteria(ids: $subscriberIds, status: [Status::Active], partitions: true); + + // a partition held by another worker is not claimed, every run checks again + return $this->process($criteria, $claimGroups, $command); } /** @return list */ diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 272df938f..f2863146b 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -38,6 +38,7 @@ use Patchlevel\EventSourcing\Tests\DbalManager; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\EphemeralPartitionProjection; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\OncePartitionedProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedBatchProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\PartitionedTransactionalBatchProjection; @@ -518,6 +519,27 @@ private function sortedPartitionIds(string $subscriberId): array return $ids; } + /** A partition that processed its inbox before its main subscription finished, finishes afterwards as well. */ + public function testPartitionsOfARunOnceSubscriberFinish(): void + { + $projection = new OncePartitionedProjection(); + $engine = $this->engine($projection); + $subscriberId = OncePartitionedProjection::SUBSCRIBER_ID; + + $engine->execute(new Setup(skipBooting: true)); + $this->createProfiles(3); + + // one message per run, so the first partitions are done long before the main subscription + $this->runUntilFinished($engine, 1); + + self::assertSame(3, $projection->handled); + self::assertSame(Status::Finished, $this->subscriptionStore->get($subscriberId)->status()); + + foreach ($this->partitions($subscriberId) as $partition) { + self::assertSame(Status::Finished, $partition->status(), $partition->id()); + } + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { diff --git a/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php b/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php new file mode 100644 index 000000000..fba9aafe2 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php @@ -0,0 +1,33 @@ +header(StreamNameHeader::class)->streamName; + } + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event): void + { + $this->handled++; + } +} From 9d0dfa78c3b66da4f61efa292dde393993635caa Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:00:00 +0200 Subject: [PATCH 35/57] Test partitions with two real worker processes Two worker processes run the engine against the same database while new events keep coming in. Every message has to be processed exactly once and in the order of its aggregate, both workers have to take part, and in the end every partition is active with an empty inbox. Skipped on SQLite, which can not be shared between processes. --- .../Subscription/ConcurrentWorkerTest.php | 256 ++++++++++++++++++ .../ConcurrentProfileProjection.php | 55 ++++ tests/Integration/Worker/partition-worker.php | 73 +++++ 3 files changed, 384 insertions(+) create mode 100644 tests/Integration/Subscription/ConcurrentWorkerTest.php create mode 100644 tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php create mode 100644 tests/Integration/Worker/partition-worker.php diff --git a/tests/Integration/Subscription/ConcurrentWorkerTest.php b/tests/Integration/Subscription/ConcurrentWorkerTest.php new file mode 100644 index 000000000..a19ec4c38 --- /dev/null +++ b/tests/Integration/Subscription/ConcurrentWorkerTest.php @@ -0,0 +1,256 @@ +}> */ + private array $workers = []; + + public function setUp(): void + { + $this->connection = DbalManager::createConnection(); + + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + self::markTestSkipped('needs a database that several processes can share'); + } + + $stopFile = tempnam(sys_get_temp_dir(), 'partition-worker-stop'); + + if ($stopFile === false) { + throw new RuntimeException('no temp file'); + } + + unlink($stopFile); + $this->stopFile = $stopFile; + } + + public function tearDown(): void + { + if (isset($this->stopFile) && !file_exists($this->stopFile)) { + touch($this->stopFile); + } + + foreach ($this->workers as [$process, $pipes]) { + foreach ($pipes as $pipe) { + fclose($pipe); + } + + proc_close($process); + } + + if (isset($this->stopFile) && file_exists($this->stopFile)) { + unlink($this->stopFile); + } + + $this->connection->close(); + } + + public function testTwoWorkersProcessEveryMessageOnceAndInOrder(): void + { + $store = new StreamDoctrineDbalStore($this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'])); + $subscriptionStore = new DoctrineSubscriptionStore($this->connection); + + (new DoctrineSchemaDirector( + $this->connection, + new ChainDoctrineSchemaConfigurator([$store, $subscriptionStore]), + ))->create(); + + $table = new Table(ConcurrentProfileProjection::TABLE); + $table->addColumn('id', Types::INTEGER)->setAutoincrement(true); + $table->addColumn('profile_id', Types::STRING)->setLength(36); + $table->addColumn('name', Types::STRING)->setLength(32); + $table->addColumn('worker', Types::STRING)->setLength(16); + $table->setPrimaryKey(['id']); + $this->connection->createSchemaManager()->createTable($table); + + $subscriberRepository = new MetadataSubscriberAccessorRepository([ + new ConcurrentProfileProjection($this->connection, 'test'), + ]); + + (new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + ))->execute(new Setup(skipBooting: true)); + + $this->startWorker('worker-a'); + $this->startWorker('worker-b'); + + /** @var Repository $repository */ + $repository = (new DefaultRepositoryManager( + new AggregateRootRegistry(['profile' => Profile::class]), + $store, + ))->get(Profile::class); + + // the events keep coming in while the workers run, round robin over the aggregates + $ids = array_map(static fn (): ProfileId => ProfileId::generate(), range(1, self::AGGREGATES)); + + foreach ($ids as $id) { + $repository->save(Profile::create($id, 'n0')); + } + + for ($i = 1; $i < self::EVENTS_PER_AGGREGATE; $i++) { + foreach ($ids as $id) { + $profile = $repository->load($id); + $profile->changeName('n' . $i); + $repository->save($profile); + } + } + + $this->waitForRows(self::AGGREGATES * self::EVENTS_PER_AGGREGATE); + + touch($this->stopFile); + $outputs = $this->stopWorkers(); + + foreach ($outputs as $worker => [$exitCode, $stdout, $stderr]) { + self::assertSame(0, $exitCode, $worker . ': ' . $stdout . $stderr); + } + + /** @var list $rows */ + $rows = $this->connection->fetchAllAssociative( + 'SELECT profile_id, name, worker FROM ' . ConcurrentProfileProjection::TABLE . ' ORDER BY id', + ); + + self::assertCount(self::AGGREGATES * self::EVENTS_PER_AGGREGATE, $rows, 'every message exactly once'); + + $names = []; + $workers = []; + + foreach ($rows as $row) { + $names[$row['profile_id']][] = $row['name']; + $workers[$row['worker']] = true; + } + + $expected = array_map(static fn (int $i): string => 'n' . $i, range(0, self::EVENTS_PER_AGGREGATE - 1)); + + foreach ($ids as $id) { + self::assertSame($expected, $names[$id->toString()] ?? [], 'in order: ' . $id->toString()); + } + + self::assertCount(2, $workers, 'both workers processed messages'); + + $subscriptions = $subscriptionStore->find(new SubscriptionCriteria(ids: [ConcurrentProfileProjection::SUBSCRIBER_ID])); + self::assertCount(self::AGGREGATES + 1, $subscriptions); + + foreach ($subscriptions as $subscription) { + self::assertSame(Status::Active, $subscription->status(), $subscription->id()); + } + + self::assertSame(0, $this->rows('subscriptions_inbox'), 'every inbox is processed'); + } + + private function startWorker(string $name): void + { + $process = proc_open( + [PHP_BINARY, __DIR__ . '/../Worker/partition-worker.php', $name, $this->stopFile], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + getenv(), + ); + + if (!is_resource($process)) { + throw new RuntimeException('worker could not be started'); + } + + $this->workers[$name] = [$process, $pipes]; + } + + private function waitForRows(int $expected): void + { + $deadline = microtime(true) + 60; + + while (microtime(true) < $deadline) { + if ($this->rows(ConcurrentProfileProjection::TABLE) >= $expected) { + return; + } + + usleep(50_000); + } + + self::fail('the workers did not process all messages in time'); + } + + private function rows(string $table): int + { + $count = $this->connection->fetchOne('SELECT COUNT(*) FROM ' . $table); + + return is_numeric($count) ? (int)$count : 0; + } + + /** @return array worker => exit code, stdout, stderr */ + private function stopWorkers(): array + { + $outputs = []; + + foreach ($this->workers as $name => [$process, $pipes]) { + $stdout = (string)stream_get_contents($pipes[1]); + $stderr = (string)stream_get_contents($pipes[2]); + + foreach ($pipes as $pipe) { + fclose($pipe); + } + + $outputs[$name] = [proc_close($process), $stdout, $stderr]; + } + + $this->workers = []; + + return $outputs; + } +} diff --git a/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php new file mode 100644 index 000000000..e5b623537 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php @@ -0,0 +1,55 @@ +header(StreamNameHeader::class)->streamName; + } + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + private function record(string $profileId, string $name): void + { + $this->connection->insert(self::TABLE, [ + 'profile_id' => $profileId, + 'name' => $name, + 'worker' => $this->worker, + ]); + } +} diff --git a/tests/Integration/Worker/partition-worker.php b/tests/Integration/Worker/partition-worker.php new file mode 100644 index 000000000..4cb8b4667 --- /dev/null +++ b/tests/Integration/Worker/partition-worker.php @@ -0,0 +1,73 @@ +parse($dbUrl), + (new Configuration())->setMiddlewares([new PostgreSQLPlatformMiddleware()]), +); + +$store = new StreamDoctrineDbalStore($connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/../Subscription/Events'])); +$subscriberRepository = new MetadataSubscriberAccessorRepository([ + new ConcurrentProfileProjection($connection, $worker), +]); + +$engine = new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + new DoctrineSubscriptionStore($connection), + $subscriberRepository, +); + +$runs = 0; +$errors = 0; + +while (!file_exists($stopFile)) { + // small claims, so the workers get in each others way as often as possible + $result = $engine->execute(new Run(limit: 5, partitionsPerClaim: 3)); + $runs++; + + foreach ($result->errors as $error) { + $errors++; + fwrite(STDERR, sprintf("%s: %s: %s\n", $worker, $error->subscriptionId, $error->message)); + } + + if (!$result instanceof ProcessedResult || !$result->finished) { + continue; + } + + usleep(10_000); +} + +echo json_encode(['runs' => $runs, 'errors' => $errors]); + +exit($errors === 0 ? 0 : 1); From f27ed67cbf0c663d5cb65c994bab03a8eb7621d3 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:06:35 +0200 Subject: [PATCH 36/57] Test batching and the event emitter with two workers The worker test also runs a projector whose batches are database transactions and one that emits an event for every message. Both have to process every message exactly once and in order, and every emitted event has to end up exactly once in the one stream of the subscriber. --- .../Subscription/ConcurrentWorkerTest.php | 59 +++++++++++++-- .../ConcurrentBatchProfileProjection.php | 75 +++++++++++++++++++ .../ConcurrentEmittingProfileProjection.php | 59 +++++++++++++++ tests/Integration/Worker/partition-worker.php | 14 +++- 4 files changed, 198 insertions(+), 9 deletions(-) create mode 100644 tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php create mode 100644 tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php diff --git a/tests/Integration/Subscription/ConcurrentWorkerTest.php b/tests/Integration/Subscription/ConcurrentWorkerTest.php index a19ec4c38..66a885305 100644 --- a/tests/Integration/Subscription/ConcurrentWorkerTest.php +++ b/tests/Integration/Subscription/ConcurrentWorkerTest.php @@ -15,6 +15,8 @@ use Patchlevel\EventSourcing\Schema\ChainDoctrineSchemaConfigurator; use Patchlevel\EventSourcing\Schema\DoctrineSchemaDirector; use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; +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; @@ -24,12 +26,18 @@ use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Tests\DbalManager; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NotificationSent; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ConcurrentBatchProfileProjection; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ConcurrentEmittingProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ConcurrentProfileProjection; use PHPUnit\Framework\Attributes\CoversNothing; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; +use function array_filter; use function array_map; +use function array_values; use function fclose; use function file_exists; use function getenv; @@ -39,6 +47,7 @@ use function proc_close; use function proc_open; use function range; +use function str_starts_with; use function stream_get_contents; use function sys_get_temp_dir; use function tempnam; @@ -100,7 +109,17 @@ public function tearDown(): void $this->connection->close(); } - public function testTwoWorkersProcessEveryMessageOnceAndInOrder(): void + /** @return iterable */ + public static function projectors(): iterable + { + yield 'plain' => ['plain', ConcurrentProfileProjection::SUBSCRIBER_ID]; + yield 'transactional batches' => ['batch', ConcurrentBatchProfileProjection::SUBSCRIBER_ID]; + yield 'event emitter' => ['emitting', ConcurrentEmittingProfileProjection::SUBSCRIBER_ID]; + } + + /** @param 'plain'|'batch'|'emitting' $projector */ + #[DataProvider('projectors')] + public function testTwoWorkersProcessEveryMessageOnceAndInOrder(string $projector, string $subscriberId): void { $store = new StreamDoctrineDbalStore($this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events'])); $subscriptionStore = new DoctrineSubscriptionStore($this->connection); @@ -119,7 +138,11 @@ public function testTwoWorkersProcessEveryMessageOnceAndInOrder(): void $this->connection->createSchemaManager()->createTable($table); $subscriberRepository = new MetadataSubscriberAccessorRepository([ - new ConcurrentProfileProjection($this->connection, 'test'), + match ($projector) { + 'plain' => new ConcurrentProfileProjection($this->connection, 'test'), + 'batch' => new ConcurrentBatchProfileProjection($this->connection, 'test'), + 'emitting' => new ConcurrentEmittingProfileProjection($this->connection, 'test'), + }, ]); (new DefaultSubscriptionEngine( @@ -128,8 +151,8 @@ public function testTwoWorkersProcessEveryMessageOnceAndInOrder(): void $subscriberRepository, ))->execute(new Setup(skipBooting: true)); - $this->startWorker('worker-a'); - $this->startWorker('worker-b'); + $this->startWorker('worker-a', $projector); + $this->startWorker('worker-b', $projector); /** @var Repository $repository */ $repository = (new DefaultRepositoryManager( @@ -184,7 +207,7 @@ public function testTwoWorkersProcessEveryMessageOnceAndInOrder(): void self::assertCount(2, $workers, 'both workers processed messages'); - $subscriptions = $subscriptionStore->find(new SubscriptionCriteria(ids: [ConcurrentProfileProjection::SUBSCRIBER_ID])); + $subscriptions = $subscriptionStore->find(new SubscriptionCriteria(ids: [$subscriberId])); self::assertCount(self::AGGREGATES + 1, $subscriptions); foreach ($subscriptions as $subscription) { @@ -192,12 +215,34 @@ public function testTwoWorkersProcessEveryMessageOnceAndInOrder(): void } self::assertSame(0, $this->rows('subscriptions_inbox'), 'every inbox is processed'); + + if ($projector !== 'emitting') { + return; + } + + // the partitions share the stream of their subscriber, every processed message emitted exactly one event + $emitted = []; + + foreach ($store->load(new Criteria(new StreamCriterion('subscription_' . $subscriberId))) as $message) { + $event = $message->event(); + self::assertInstanceOf(NotificationSent::class, $event); + $emitted[$event->profileId->toString()] = ($emitted[$event->profileId->toString()] ?? 0) + 1; + } + + self::assertSame(['subscription_' . $subscriberId], array_values(array_filter( + $store->streams(), + static fn (string $stream): bool => str_starts_with($stream, 'subscription_'), + )), 'one stream for all partitions'); + + foreach ($ids as $id) { + self::assertSame(self::EVENTS_PER_AGGREGATE, $emitted[$id->toString()] ?? 0, 'emitted once per message: ' . $id->toString()); + } } - private function startWorker(string $name): void + private function startWorker(string $name, string $projector): void { $process = proc_open( - [PHP_BINARY, __DIR__ . '/../Worker/partition-worker.php', $name, $this->stopFile], + [PHP_BINARY, __DIR__ . '/../Worker/partition-worker.php', $name, $this->stopFile, $projector], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, diff --git a/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php new file mode 100644 index 000000000..35b2916b4 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php @@ -0,0 +1,75 @@ +header(StreamNameHeader::class)->streamName; + } + + #[BatchBegin] + public function begin(): void + { + $this->connection->beginTransaction(); + } + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $event): void + { + $this->record($event->profileId->toString(), $event->name); + } + + #[BatchFlush(afterMessages: 3)] + public function flush(): void + { + $this->connection->commit(); + } + + #[BatchRollback] + public function rollback(): void + { + $this->connection->rollBack(); + } + + private function record(string $profileId, string $name): void + { + $this->connection->insert(ConcurrentProfileProjection::TABLE, [ + 'profile_id' => $profileId, + 'name' => $name, + 'worker' => $this->worker, + ]); + } +} diff --git a/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php new file mode 100644 index 000000000..8b49dc72b --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php @@ -0,0 +1,59 @@ +header(StreamNameHeader::class)->streamName; + } + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event, EventEmitter $emitter): void + { + $this->record($event->profileId, $event->name, $emitter); + } + + #[Subscribe(NameChanged::class)] + public function handleNameChanged(NameChanged $event, EventEmitter $emitter): void + { + $this->record($event->profileId, $event->name, $emitter); + } + + private function record(ProfileId $profileId, string $name, EventEmitter $emitter): void + { + $this->connection->insert(ConcurrentProfileProjection::TABLE, [ + 'profile_id' => $profileId->toString(), + 'name' => $name, + 'worker' => $this->worker, + ]); + + $emitter->emit([new NotificationSent($profileId)]); + } +} diff --git a/tests/Integration/Worker/partition-worker.php b/tests/Integration/Worker/partition-worker.php index 4cb8b4667..ba712a16d 100644 --- a/tests/Integration/Worker/partition-worker.php +++ b/tests/Integration/Worker/partition-worker.php @@ -4,7 +4,7 @@ /** * A subscription worker for ConcurrentWorkerTest: runs the engine until the stop file exists. - * Arguments: worker name, stop file. The database comes from DB_URL. + * Arguments: worker name, stop file, projector (plain, batch or emitting). The database comes from DB_URL. */ use Doctrine\DBAL\Configuration; @@ -19,13 +19,17 @@ use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\EventEmitterResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ConcurrentBatchProfileProjection; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ConcurrentEmittingProfileProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\ConcurrentProfileProjection; require __DIR__ . '/../../../vendor/autoload.php'; $worker = $argv[1] ?? 'worker'; $stopFile = $argv[2] ?? throw new RuntimeException('missing stop file'); +$projector = $argv[3] ?? 'plain'; $dbUrl = getenv('DB_URL'); if (!is_string($dbUrl)) { @@ -39,13 +43,19 @@ $store = new StreamDoctrineDbalStore($connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/../Subscription/Events'])); $subscriberRepository = new MetadataSubscriberAccessorRepository([ - new ConcurrentProfileProjection($connection, $worker), + match ($projector) { + 'plain' => new ConcurrentProfileProjection($connection, $worker), + 'batch' => new ConcurrentBatchProfileProjection($connection, $worker), + 'emitting' => new ConcurrentEmittingProfileProjection($connection, $worker), + default => throw new RuntimeException('unknown projector ' . $projector), + }, ]); $engine = new DefaultSubscriptionEngine( new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), new DoctrineSubscriptionStore($connection), $subscriberRepository, + argumentResolvers: [new EventEmitterResolver($store)], ); $runs = 0; From 78dddedbb61c2778fce5ee7794821ac6da2bd254 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:21:27 +0200 Subject: [PATCH 37/57] Set the partitioner on the subscriber class Instead of marking a method that returns the partition key, #[Partition] is now set on the subscriber class and takes a partitioner, e.g. #[Partition(new StreamPartitioner())]. Partitioners are small reusable classes implementing the Partitioner interface, StreamPartitioner creates one partition per stream. The partitioner is read from the attribute when it is needed, so it is not part of the cached metadata. --- docs/subscription.md | 71 ++++++------ src/Attribute/Partition.php | 7 +- .../AttributeSubscriberMetadataFactory.php | 53 ++------- .../Subscriber/DuplicatePartitionMethod.php | 25 ----- .../Subscriber/InvalidPartitionMethod.php | 40 ------- .../InvalidPartitionedSubscriberId.php | 26 +++++ src/Metadata/Subscriber/PartitionMetadata.php | 2 +- .../Engine/InvalidPartitionKey.php | 12 +-- src/Subscription/Engine/PartitionRouter.php | 15 +-- src/Subscription/Partitioner/Partitioner.php | 13 +++ .../Partitioner/StreamPartitioner.php | 17 +++ .../Subscriber/MetadataSubscriberAccessor.php | 18 ++-- tests/Architecture/LayerDependenciesTest.php | 8 +- .../PartitionedCountingProjector.php | 10 +- .../Subscription/ConfigurablePartitioner.php | 26 +++++ .../PartitionedSubscriptionTest.php | 14 +-- .../ConcurrentBatchProfileProjection.php | 10 +- .../ConcurrentEmittingProfileProjection.php | 10 +- .../ConcurrentProfileProjection.php | 10 +- .../EphemeralPartitionProjection.php | 11 +- .../Subscriber/OncePartitionedProjection.php | 10 +- .../PartitionedBatchProfileProjection.php | 10 +- .../PartitionedProfileProjection.php | 20 +--- ...artitionedTransactionalBatchProjection.php | 10 +- ...AttributeSubscriberMetadataFactoryTest.php | 101 +++--------------- .../PartitionCircuitBreakerSubscriberTest.php | 15 +-- .../Engine/Listener/RetrySubscriberTest.php | 7 +- .../Engine/MessageProcessorTest.php | 8 +- .../MetadataSubscriberAccessorTest.php | 34 ++++++ 29 files changed, 249 insertions(+), 364 deletions(-) delete mode 100644 src/Metadata/Subscriber/DuplicatePartitionMethod.php delete mode 100644 src/Metadata/Subscriber/InvalidPartitionMethod.php create mode 100644 src/Metadata/Subscriber/InvalidPartitionedSubscriberId.php create mode 100644 src/Subscription/Partitioner/Partitioner.php create mode 100644 src/Subscription/Partitioner/StreamPartitioner.php create mode 100644 tests/Integration/Subscription/ConfigurablePartitioner.php diff --git a/docs/subscription.md b/docs/subscription.md index c576349a3..a4b876eed 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -323,17 +323,11 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\PartitionKey; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; #[Projector('profile_1')] +#[Partition(new TenantPartitioner())] final class ProfileProjector { - #[Partition] - public function partition(Message $message): string - { - return $message->header(TenantHeader::class)->tenant; - } - #[Subscribe(ProfileCreated::class)] public function onProfileCreated(ProfileCreated $event, #[PartitionKey] string $tenant): void { @@ -884,24 +878,20 @@ With `#[Partition]` the subscriber is split into one subscription per partition The partitions are processed independently, so several workers can process them in parallel, and an error only stops the partition it happened in. -The method marked with `#[Partition]` gets the `Message` and returns the partition key. +The `#[Partition]` attribute on the subscriber class takes a partitioner, which returns the partition key of a message. Choose a key from your domain that groups everything that has to be processed in order, like the tenant or the aggregate. +The `StreamPartitioner` creates one partition per stream, e.g. per aggregate. ```php use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; #[Projector('profile_1')] +#[Partition(new StreamPartitioner())] final class ProfileProjector { - #[Partition] - public function partition(Message $message): string - { - return $message->header(TenantHeader::class)->tenant; - } - #[Subscribe(ProfileCreated::class)] public function onProfileCreated(ProfileCreated $event): void { @@ -909,6 +899,36 @@ final class ProfileProjector } } ``` +#### Custom partitioner + +For another key, implement the `Partitioner` interface. +The partitioner is created by the attribute, so it can only get constant values, e.g. the name of a header. + +```php +use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Partitioner\Partitioner; + +final class TenantPartitioner implements Partitioner +{ + public function __invoke(Message $message): string + { + return $message->header(TenantHeader::class)->tenant; + } +} +``` +```php +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\Projector; + +#[Projector('profile_1')] +#[Partition(new TenantPartitioner())] +final class ProfileProjector +{ + // ... +} +``` +#### How partitions work + The subscription with the subscriber id is the main subscription. It never calls your subscribe methods. Instead, it resolves the partition key of every message exactly once and puts the message into the inbox of that partition. Partitions are created on the fly as new keys appear, get the id `{subscriber id}#{partition key}` @@ -933,7 +953,7 @@ The id of a partitioned subscriber must not contain `#`. ::: :::tip -Changing the partition method is safe: messages that were already routed stay in their partition. +Changing the partitioner is safe: messages that were already routed stay in their partition. Only the order between the old and the new partition of the same aggregate is not guaranteed during the change. ::: @@ -957,18 +977,12 @@ The next message of its key creates it again, so nothing is lost. ```php use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; #[Projector('profile_1')] +#[Partition(new StreamPartitioner(), removeIdleAfter: 3600)] final class ProfileProjector { - #[Partition(removeIdleAfter: 3600)] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - // ... } ``` @@ -989,17 +1003,12 @@ The default is 5 failures in a row, you can change it on the attribute. ```php use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; -use Patchlevel\EventSourcing\Message\Message; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; #[Projector('profile_1')] +#[Partition(new StreamPartitioner(), maxConsecutiveFailures: 20)] final class ProfileProjector { - #[Partition(maxConsecutiveFailures: 20)] - public function partition(Message $message): string - { - return $message->header(TenantHeader::class)->tenant; - } - // ... } ``` diff --git a/src/Attribute/Partition.php b/src/Attribute/Partition.php index 2673cb52b..3ac6e9415 100644 --- a/src/Attribute/Partition.php +++ b/src/Attribute/Partition.php @@ -5,14 +5,17 @@ namespace Patchlevel\EventSourcing\Attribute; use Attribute; +use Patchlevel\EventSourcing\Subscription\Partitioner\Partitioner; /** - * Marks the method that returns the partition key of a message, signature `(Message): string`. + * Splits a subscriber into partitions, the partitioner returns the partition key of every message, + * e.g. `#[Partition(new StreamPartitioner())]`. */ -#[Attribute(Attribute::TARGET_METHOD)] +#[Attribute(Attribute::TARGET_CLASS)] final class Partition { public function __construct( + public readonly Partitioner $partitioner, /** * After that many partitions of the subscriber failed in a row, without any success in between, the failure is * treated as systemic (e.g. the database is down): the main subscription goes into the error status instead of diff --git a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php index 0e286e132..7fc99ba9a 100644 --- a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php @@ -19,12 +19,10 @@ use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; -use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\Subscription; use ReflectionAttribute; use ReflectionClass; use ReflectionMethod; -use ReflectionNamedType; use Symfony\Component\TypeInfo\TypeResolver\TypeResolver; use function array_key_exists; @@ -73,7 +71,6 @@ public function metadata(string $subscriber): SubscriberMetadata $flushAfterMessages = null; $shouldFlushMethod = null; $rollbackBatchMethod = null; - $partitionMethod = null; foreach ($methods as $method) { $attributes = $method->getAttributes(Subscribe::class); @@ -169,29 +166,6 @@ public function metadata(string $subscriber): SubscriberMetadata $setupMethod = $method->getName(); } - $partitionAttributes = $method->getAttributes(Partition::class); - - if ($partitionAttributes !== []) { - if ($partitionMethod !== null) { - throw new DuplicatePartitionMethod( - $subscriber, - $partitionMethod->method, - $method->getName(), - ); - } - - if (!$this->isPartitionMethodSignature($method)) { - throw InvalidPartitionMethod::invalidSignature($subscriber, $method->getName()); - } - - $partition = $partitionAttributes[0]->newInstance(); - $partitionMethod = new PartitionMetadata( - $method->getName(), - $partition->maxConsecutiveFailures, - $partition->removeIdleAfter, - ); - } - if ($method->getAttributes(Cleanup::class)) { if ($cleanupMethod !== null) { throw new DuplicateCleanupMethod( @@ -261,8 +235,16 @@ public function metadata(string $subscriber): SubscriberMetadata ); } - if ($partitionMethod !== null && str_contains($subscriberInfo->id, Subscription::PARTITION_SEPARATOR)) { - throw InvalidPartitionMethod::invalidSubscriberId($subscriber, $subscriberInfo->id); + $partitionAttributes = $reflector->getAttributes(Partition::class); + $partitionMetadata = null; + + if ($partitionAttributes !== []) { + if (str_contains($subscriberInfo->id, Subscription::PARTITION_SEPARATOR)) { + throw new InvalidPartitionedSubscriberId($subscriber, $subscriberInfo->id); + } + + $partition = $partitionAttributes[0]->newInstance(); + $partitionMetadata = new PartitionMetadata($partition->maxConsecutiveFailures, $partition->removeIdleAfter); } $metadata = new SubscriberMetadata( @@ -278,7 +260,7 @@ public function metadata(string $subscriber): SubscriberMetadata $reflector->getAttributes(EnableEventEmittingDuringBoot::class) !== [], $reflector->getAttributes(DisableEventEmitting::class) !== [], $batch, - $partitionMethod, + $partitionMetadata, ); $this->subscriberMetadata[$subscriber] = $metadata; @@ -317,19 +299,6 @@ private function subscribeMethod(ReflectionMethod $method): SubscribeMethodMetad ); } - private function isPartitionMethodSignature(ReflectionMethod $method): bool - { - $parameters = $method->getParameters(); - - if (count($parameters) !== 1) { - return false; - } - - $type = $parameters[0]->getType(); - - return $type instanceof ReflectionNamedType && $type->getName() === Message::class; - } - /** @param array $subscribeMethods */ private function hasBatchArgument(array $subscribeMethods): bool { diff --git a/src/Metadata/Subscriber/DuplicatePartitionMethod.php b/src/Metadata/Subscriber/DuplicatePartitionMethod.php deleted file mode 100644 index 225a5d8b8..000000000 --- a/src/Metadata/Subscriber/DuplicatePartitionMethod.php +++ /dev/null @@ -1,25 +0,0 @@ - $subscriber */ private function partitionKey(Message $message, Subscription $main, MetadataSubscriberAccessor $subscriber): string { - $partitionMethod = $subscriber->partitionMethod(); + $partitioner = $subscriber->partitioner(); - if ($partitionMethod === null) { - throw new UnexpectedError(sprintf('The subscriber of "%s" has no partition method.', $main->id())); + if ($partitioner === null) { + throw new UnexpectedError(sprintf('The subscriber of "%s" has no partitioner.', $main->id())); } - $partitionKey = $partitionMethod($message); - - if (!is_string($partitionKey)) { - throw InvalidPartitionKey::notAString($main->id(), $partitionKey); - } + $partitionKey = $partitioner($message); if ($partitionKey === '') { throw InvalidPartitionKey::empty($main->id()); diff --git a/src/Subscription/Partitioner/Partitioner.php b/src/Subscription/Partitioner/Partitioner.php new file mode 100644 index 000000000..9e4ae5a8a --- /dev/null +++ b/src/Subscription/Partitioner/Partitioner.php @@ -0,0 +1,13 @@ +header(StreamNameHeader::class)->streamName; + } +} diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php index 4bc490342..a0e08b8cd 100644 --- a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php @@ -5,10 +5,13 @@ namespace Patchlevel\EventSourcing\Subscription\Subscriber; use Closure; +use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; +use Patchlevel\EventSourcing\Subscription\Partitioner\Partitioner; +use ReflectionClass; use Throwable; use function array_key_exists; @@ -20,6 +23,8 @@ final class MetadataSubscriberAccessor /** @var array> */ private array $subscribeCache = []; + private Partitioner|null $partitioner = null; + /** @param T $subscriber */ public function __construct( private readonly object $subscriber, @@ -84,16 +89,17 @@ public function failedMethod(): Closure|null return $this->subscriber->$method(...); } - /** @return Closure(Message):mixed|null */ - public function partitionMethod(): Closure|null + /** The partitioner of a partitioned subscriber, created from its #[Partition] attribute. */ + public function partitioner(): Partitioner|null { - $method = $this->metadata->partition?->method; - - if ($method === null) { + if ($this->metadata->partition === null) { return null; } - return $this->subscriber->$method(...); + return $this->partitioner ??= (new ReflectionClass($this->subscriber)) + ->getAttributes(Partition::class)[0] + ->newInstance() + ->partitioner; } /** @return list */ diff --git a/tests/Architecture/LayerDependenciesTest.php b/tests/Architecture/LayerDependenciesTest.php index 1fbf6e3bf..2e4e559a5 100644 --- a/tests/Architecture/LayerDependenciesTest.php +++ b/tests/Architecture/LayerDependenciesTest.php @@ -27,7 +27,13 @@ public function testAggregateCanOnlyDependOnAllowedLayers(): Rule public function testAttributeCanOnlyDependOnAllowedLayers(): Rule { - return $this->layerCanOnlyDependOnAllowedLayers($this->layer('Attribute')); + return $this->layerCanOnlyDependOnAllowedLayers( + $this->layer('Attribute'), + [ + // #[Partition] on a class takes a partitioner + $this->layer('Subscription\Partitioner'), + ], + ); } public function testClockCanOnlyDependOnAllowedLayers(): Rule diff --git a/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php b/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php index d597a7a00..430f8ad3f 100644 --- a/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php +++ b/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php @@ -7,23 +7,17 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Benchmark\BasicImplementation\Events\ProfileCreated; /** One partition per aggregate, the projection only counts the handled events. */ #[Projector('partitioned_counting')] +#[Partition(new StreamPartitioner())] final class PartitionedCountingProjector { public int $handled = 0; - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[Subscribe(ProfileCreated::class)] public function onProfileCreated(ProfileCreated $event): void { diff --git a/tests/Integration/Subscription/ConfigurablePartitioner.php b/tests/Integration/Subscription/ConfigurablePartitioner.php new file mode 100644 index 000000000..3d891564a --- /dev/null +++ b/tests/Integration/Subscription/ConfigurablePartitioner.php @@ -0,0 +1,26 @@ +header(StreamNameHeader::class)->streamName; + } +} diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index f2863146b..b3e5d8c1a 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -71,10 +71,12 @@ public function setUp(): void { $this->connection = DbalManager::createConnection(); $this->clock = new FrozenClock(new DateTimeImmutable('2021-01-01T00:00:00')); + ConfigurablePartitioner::$key = null; } public function tearDown(): void { + ConfigurablePartitioner::$key = null; $this->connection->close(); gc_collect_cycles(); @@ -149,7 +151,7 @@ public function testChangedPartitionKeyDoesNotLoseMessages(): void { $projection = new PartitionedProfileProjection(); $engine = $this->engine($projection); - $projection->partitionKey = static fn (Message $message): string => 'v1-' . $message->header(StreamNameHeader::class)->streamName; + ConfigurablePartitioner::$key = static fn (Message $message): string => 'v1-' . $message->header(StreamNameHeader::class)->streamName; $engine->execute(new Setup(skipBooting: true)); @@ -162,7 +164,7 @@ public function testChangedPartitionKeyDoesNotLoseMessages(): void $this->runUntilFinished($engine); // deploy: the key method changes and the bug is fixed while the partition is still in error - $projection->partitionKey = static fn (Message $message): string => 'v2-' . $message->header(StreamNameHeader::class)->streamName; + ConfigurablePartitioner::$key = static fn (Message $message): string => 'v2-' . $message->header(StreamNameHeader::class)->streamName; $projection->failingNames = []; $this->changeName($profileId, 'n3'); @@ -183,7 +185,7 @@ public function testChangedPartitionKeyDoesNotLoseMessages(): void public function testNonDeterministicPartitionKeyProcessesEveryMessageExactlyOnce(): void { $projection = new PartitionedProfileProjection(); - $projection->partitionKey = static fn (Message $message): string => 'bucket-' . random_int(1, 5); + ConfigurablePartitioner::$key = static fn (Message $message): string => 'bucket-' . random_int(1, 5); $engine = $this->engine($projection); $engine->execute(new Setup(skipBooting: true)); @@ -232,7 +234,7 @@ public function testIdlePartitionsAreNotClaimed(): void public function testRunWithLimitProcessesTheInboxInChunks(): void { $projection = new PartitionedProfileProjection(); - $projection->partitionKey = static fn (Message $message): string => 'all'; + ConfigurablePartitioner::$key = static fn (Message $message): string => 'all'; $engine = $this->engine($projection); $engine->execute(new Setup(skipBooting: true)); @@ -389,7 +391,7 @@ public function testPartitionsOfMessagesRoutedBeforeAnErrorAreCreated(): void [$firstId, $secondId, $brokenId] = $this->createProfiles(3); - $projection->partitionKey = static function (Message $message) use ($brokenId): string { + ConfigurablePartitioner::$key = static function (Message $message) use ($brokenId): string { $streamName = $message->header(StreamNameHeader::class)->streamName; if ($streamName === 'profile-' . $brokenId) { @@ -439,7 +441,7 @@ public static function sameRun(): iterable public function testKeysThatOnlyDifferInCaseAreDifferentPartitions(bool $sameRun): void { $projection = new PartitionedProfileProjection(); - $projection->partitionKey = static function (Message $message): string { + ConfigurablePartitioner::$key = static function (Message $message): string { $event = $message->event(); return $event instanceof ProfileCreated ? 'tenant-' . $event->name : 'other'; diff --git a/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php index 35b2916b4..522f1185b 100644 --- a/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php @@ -11,13 +11,13 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; /** Like ConcurrentProfileProjection, but every batch of three messages is its own database transaction. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new StreamPartitioner())] final class ConcurrentBatchProfileProjection { public const SUBSCRIBER_ID = 'concurrent_batch_profile'; @@ -28,12 +28,6 @@ public function __construct( ) { } - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[BatchBegin] public function begin(): void { diff --git a/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php index 8b49dc72b..b60835e3d 100644 --- a/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php @@ -8,8 +8,7 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\Subscriber\EventEmitter\EventEmitter; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NotificationSent; @@ -18,6 +17,7 @@ /** Like ConcurrentProfileProjection, but every processed message also emits an event into the subscriber stream. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new StreamPartitioner())] final class ConcurrentEmittingProfileProjection { public const SUBSCRIBER_ID = 'concurrent_emitting_profile'; @@ -28,12 +28,6 @@ public function __construct( ) { } - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event, EventEmitter $emitter): void { diff --git a/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php index e5b623537..0015929b3 100644 --- a/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php @@ -8,13 +8,13 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; /** Writes every processed message with the name of the worker, in the transaction of the subscription. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new StreamPartitioner())] final class ConcurrentProfileProjection { public const SUBSCRIBER_ID = 'concurrent_profile'; @@ -26,12 +26,6 @@ public function __construct( ) { } - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event): void { diff --git a/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php b/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php index bebd4e85a..beceffc09 100644 --- a/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php +++ b/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php @@ -7,15 +7,16 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use RuntimeException; use function in_array; +/** The partition is set on the class with a partitioner. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new StreamPartitioner(), removeIdleAfter: 60)] final class EphemeralPartitionProjection { public const SUBSCRIBER_ID = 'ephemeral_profile'; @@ -26,12 +27,6 @@ final class EphemeralPartitionProjection /** @var list */ public array $failingNames = []; - #[Partition(removeIdleAfter: 60)] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event): void { diff --git a/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php b/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php index fba9aafe2..1ef49afdd 100644 --- a/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php +++ b/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php @@ -7,24 +7,18 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; #[Subscriber(self::SUBSCRIBER_ID, RunMode::Once)] +#[Partition(new StreamPartitioner())] final class OncePartitionedProjection { public const SUBSCRIBER_ID = 'once_profile'; public int $handled = 0; - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event): void { diff --git a/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php index ca7aa6f72..f841c41ad 100644 --- a/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php @@ -11,13 +11,13 @@ use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; /** Collects the names of a batch and only stores them on flush. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new StreamPartitioner())] final class PartitionedBatchProfileProjection { public const SUBSCRIBER_ID = 'partitioned_batch_profile'; @@ -27,12 +27,6 @@ final class PartitionedBatchProfileProjection public int $flushCount = 0; - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - /** @param ArrayObject $state */ #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event, #[BatchState] diff --git a/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php index ca7c0e429..158ddf117 100644 --- a/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php @@ -4,20 +4,20 @@ namespace Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber; -use Closure; use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\PartitionKey; use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\ConfigurablePartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use RuntimeException; use function in_array; +/** The key comes from the ConfigurablePartitioner, one partition per stream unless a test changes it. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new ConfigurablePartitioner())] final class PartitionedProfileProjection { public const SUBSCRIBER_ID = 'partitioned_profile'; @@ -33,20 +33,6 @@ final class PartitionedProfileProjection /** @var list names that throw while processing */ public array $failingNames = []; - /** @var Closure(Message):string */ - public Closure $partitionKey; - - public function __construct() - { - $this->partitionKey = static fn (Message $message): string => $message->header(StreamNameHeader::class)->streamName; - } - - #[Partition] - public function partition(Message $message): string - { - return ($this->partitionKey)($message); - } - #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event, #[PartitionKey] string $partitionKey,): void diff --git a/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php index bcab45e4c..f8a62ec69 100644 --- a/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php +++ b/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php @@ -14,8 +14,7 @@ use Patchlevel\EventSourcing\Attribute\Projector; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; -use Patchlevel\EventSourcing\Message\Message; -use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use RuntimeException; @@ -26,6 +25,7 @@ * so overlapping batches of different partitions would roll back the wrong writes. */ #[Projector(self::SUBSCRIBER_ID)] +#[Partition(new StreamPartitioner())] final class PartitionedTransactionalBatchProjection { public const SUBSCRIBER_ID = 'partitioned_transactional_batch'; @@ -47,12 +47,6 @@ public function create(): void $this->connection->createSchemaManager()->createTable($table); } - #[Partition] - public function partition(Message $message): string - { - return $message->header(StreamNameHeader::class)->streamName; - } - #[BatchBegin] public function begin(): stdClass { diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index c06011a1f..8266a1d15 100644 --- a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -31,17 +31,17 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateCleanupMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateFailedMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateFlushMethod; -use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicatePartitionMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateRollbackBatchMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSetupMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateShouldFlushMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSubscribeMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateTeardownMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\IncompleteBatchMethods; -use Patchlevel\EventSourcing\Metadata\Subscriber\InvalidPartitionMethod; +use Patchlevel\EventSourcing\Metadata\Subscriber\InvalidPartitionedSubscriberId; use Patchlevel\EventSourcing\Metadata\Subscriber\MixedTeardownAndCleanupMethods; use Patchlevel\EventSourcing\Metadata\Subscriber\PartitionMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileCreated; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; @@ -807,122 +807,53 @@ class { self::assertSame('custom_strategy', $metadata->retryStrategy); } - public function testPartitionMethod(): void + public function testPartition(): void { $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] class { - #[Partition] - public function partition(Message $message): string - { - return 'key'; - } }; $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadata = $metadataFactory->metadata($subscriber::class); - self::assertEquals(new PartitionMetadata('partition', 5), $metadata->partition); + self::assertEquals(new PartitionMetadata(5, null), $metadata->partition); } - public function testNoPartitionMethod(): void + public function testPartitionWithOptions(): void { - $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner(), maxConsecutiveFailures: 3, removeIdleAfter: 60)] class { }; $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadata = $metadataFactory->metadata($subscriber::class); - self::assertNull($metadata->partition); + self::assertEquals(new PartitionMetadata(3, 60), $metadata->partition); } - public function testDuplicatePartitionMethodException(): void + public function testNoPartition(): void { - $this->expectException(DuplicatePartitionMethod::class); - - $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] - class { - #[Partition] - public function partition1(Message $message): string - { - return 'key'; - } - - #[Partition] - public function partition2(Message $message): string - { - return 'key'; - } - }; - - $metadataFactory = new AttributeSubscriberMetadataFactory(); - $metadataFactory->metadata($subscriber::class); - } - - public function testPartitionMethodWithInvalidSignature(): void - { - $this->expectException(InvalidPartitionMethod::class); - - $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] class { - #[Partition] - public function partition(Message $message, string $other): string - { - return $other; - } }; $metadataFactory = new AttributeSubscriberMetadataFactory(); - $metadataFactory->metadata($subscriber::class); - } - - public function testPartitionMethodWithEventArgument(): void - { - $this->expectException(InvalidPartitionMethod::class); - - $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] - class { - #[Partition] - public function partition(ProfileVisited $event): string - { - return 'key'; - } - }; + $metadata = $metadataFactory->metadata($subscriber::class); - $metadataFactory = new AttributeSubscriberMetadataFactory(); - $metadataFactory->metadata($subscriber::class); + self::assertNull($metadata->partition); } public function testPartitionedSubscriberIdWithSeparator(): void { - $this->expectException(InvalidPartitionMethod::class); + $this->expectException(InvalidPartitionedSubscriberId::class); $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] class { - #[Partition] - public function partition(Message $message): string - { - return 'key'; - } }; - $metadataFactory = new AttributeSubscriberMetadataFactory(); - $metadataFactory->metadata($subscriber::class); - } - - public function testPartitionMethodWithMaxConsecutiveFailures(): void - { - $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] - class { - #[Partition(maxConsecutiveFailures: 20)] - public function partition(Message $message): string - { - return 'key'; - } - }; - - $metadataFactory = new AttributeSubscriberMetadataFactory(); - - self::assertEquals(new PartitionMetadata('partition', 20), $metadataFactory->metadata($subscriber::class)->partition); + (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class); } } diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php index 949fead54..a3b6e3f86 100644 --- a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -20,6 +20,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Listener\PartitionCircuitBreakerSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -224,26 +225,16 @@ private function breaker(int $maxConsecutiveFailures): PartitionCircuitBreakerSu { $subscriber = $maxConsecutiveFailures === 1 ? new #[Subscriber('profile', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner(), maxConsecutiveFailures: 1)] class { - #[Partition(maxConsecutiveFailures: 1)] - public function partition(Message $message): string - { - return 'key'; - } - #[Subscribe(ProfileVisited::class)] public function on(ProfileVisited $event): void { } } : new #[Subscriber('profile', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner(), maxConsecutiveFailures: 3)] class { - #[Partition(maxConsecutiveFailures: 3)] - public function partition(Message $message): string - { - return 'key'; - } - #[Subscribe(ProfileVisited::class)] public function on(ProfileVisited $event): void { diff --git a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php index aa342a858..143b756b2 100644 --- a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -15,6 +15,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageSuccess; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -181,12 +182,8 @@ class { public function testRoutingDoesNotResetTheRetriesOfThePartitionedMain(): void { $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] class { - #[Partition] - public function partition(Message $message): string - { - return 'key'; - } }; $main = new Subscription('test', status: Status::Active, retryAttempt: 3); diff --git a/tests/Unit/Subscription/Engine/MessageProcessorTest.php b/tests/Unit/Subscription/Engine/MessageProcessorTest.php index 96d498009..e0ce64ebc 100644 --- a/tests/Unit/Subscription/Engine/MessageProcessorTest.php +++ b/tests/Unit/Subscription/Engine/MessageProcessorTest.php @@ -13,6 +13,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\ProcessingStopped; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; @@ -184,15 +185,10 @@ public function onProfileVisited(ProfileVisited $event): void public function testPartitionGetsItsPartitionKey(): void { $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] class { public string|null $partitionKey = null; - #[Partition] - public function partition(Message $message): string - { - return 'tenant-a'; - } - #[Subscribe(ProfileVisited::class)] public function onProfileVisited(ProfileVisited $event, #[PartitionKey] string $partitionKey,): void diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php index 8fdde8775..71f1b1122 100644 --- a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php +++ b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php @@ -6,14 +6,18 @@ use Patchlevel\EventSourcing\Attribute\Cleanup; use Patchlevel\EventSourcing\Attribute\OnFailed; +use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Attribute\Teardown; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; +use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; +use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileId; use Patchlevel\EventSourcing\Tests\Unit\Fixture\ProfileVisited; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -139,6 +143,36 @@ class { self::assertSame($subscriber, $accessor->subscriber()); } + public function testPartitioner(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] + class { + }; + + $accessor = $this->accessor($subscriber); + $partitioner = $accessor->partitioner(); + + self::assertInstanceOf(StreamPartitioner::class, $partitioner); + self::assertSame('profile-1', $partitioner($this->streamMessage())); + self::assertSame($partitioner, $accessor->partitioner(), 'created once'); + } + + public function testNoPartitioner(): void + { + $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] + class { + }; + + self::assertNull($this->accessor($subscriber)->partitioner()); + } + + private function streamMessage(): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString('1'))) + ->withHeader(new StreamNameHeader('profile-1')); + } + public function testCleanupMethod(): void { $subscriber = new #[Subscriber('profile', RunMode::FromBeginning)] From 225f8758781a694681c7a91e7265b2bc69c849c6 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:29:16 +0200 Subject: [PATCH 38/57] Add bucket, tag and hashed partitioners BucketPartitioner spreads the keys of another partitioner over a fixed number of partitions, so the number of partitions no longer grows with the aggregates. TagPartitioner uses the tag with a prefix as key, for the tags of the dynamic consistency boundary. HashedPartitioner hashes the key of another partitioner, so it is always short and any characters are fine. --- docs/subscription.md | 27 +++++++ .../Partitioner/BucketPartitioner.php | 34 +++++++++ .../Partitioner/HashedPartitioner.php | 27 +++++++ src/Subscription/Partitioner/TagNotUnique.php | 28 +++++++ .../Partitioner/TagPartitioner.php | 50 +++++++++++++ .../PartitionedSubscriptionTest.php | 28 +++++++ .../Partitioner/BucketPartitionerTest.php | 74 +++++++++++++++++++ .../Partitioner/HashedPartitionerTest.php | 44 +++++++++++ .../Partitioner/TagPartitionerTest.php | 55 ++++++++++++++ 9 files changed, 367 insertions(+) create mode 100644 src/Subscription/Partitioner/BucketPartitioner.php create mode 100644 src/Subscription/Partitioner/HashedPartitioner.php create mode 100644 src/Subscription/Partitioner/TagNotUnique.php create mode 100644 src/Subscription/Partitioner/TagPartitioner.php create mode 100644 tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php create mode 100644 tests/Unit/Subscription/Partitioner/HashedPartitionerTest.php create mode 100644 tests/Unit/Subscription/Partitioner/TagPartitionerTest.php diff --git a/docs/subscription.md b/docs/subscription.md index a4b876eed..2aa55f9f8 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -899,6 +899,33 @@ final class ProfileProjector } } ``` +#### Built-in partitioners + +* `StreamPartitioner`: one partition per stream, e.g. per aggregate. +* `TagPartitioner`: the tag with a prefix is the key, e.g. `new TagPartitioner('course:')` for the tags of the + dynamic consistency boundary. A message needs exactly one tag with the prefix. +* `BucketPartitioner`: spreads the keys of another partitioner over a fixed number of partitions, + e.g. `new BucketPartitioner(new StreamPartitioner(), 16)`. Everything with the same key stays in the same bucket. +* `HashedPartitioner`: hashes the key of another partitioner, so it is always short and any characters are fine. + +```php +use Patchlevel\EventSourcing\Attribute\Partition; +use Patchlevel\EventSourcing\Attribute\Projector; +use Patchlevel\EventSourcing\Subscription\Partitioner\BucketPartitioner; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; + +#[Projector('profile_1')] +#[Partition(new BucketPartitioner(new StreamPartitioner(), 16))] +final class ProfileProjector +{ + // ... +} +``` +:::tip +With one partition per aggregate the number of partitions grows with the aggregates. +Use the `BucketPartitioner` for a fixed number, or remove idle partitions, see below. +::: + #### Custom partitioner For another key, implement the `Partitioner` interface. diff --git a/src/Subscription/Partitioner/BucketPartitioner.php b/src/Subscription/Partitioner/BucketPartitioner.php new file mode 100644 index 000000000..5fe355557 --- /dev/null +++ b/src/Subscription/Partitioner/BucketPartitioner.php @@ -0,0 +1,34 @@ +partitioner)($message)) % $this->buckets); + } +} diff --git a/src/Subscription/Partitioner/HashedPartitioner.php b/src/Subscription/Partitioner/HashedPartitioner.php new file mode 100644 index 000000000..4fccf6df0 --- /dev/null +++ b/src/Subscription/Partitioner/HashedPartitioner.php @@ -0,0 +1,27 @@ +algorithm, ($this->partitioner)($message)); + } +} diff --git a/src/Subscription/Partitioner/TagNotUnique.php b/src/Subscription/Partitioner/TagNotUnique.php new file mode 100644 index 000000000..4d2149152 --- /dev/null +++ b/src/Subscription/Partitioner/TagNotUnique.php @@ -0,0 +1,28 @@ + $tags */ + public static function ambiguous(string $prefix, array $tags): self + { + return new self(sprintf( + 'The message has several tags with the prefix "%s" to partition by: %s.', + $prefix, + implode(', ', $tags), + )); + } +} diff --git a/src/Subscription/Partitioner/TagPartitioner.php b/src/Subscription/Partitioner/TagPartitioner.php new file mode 100644 index 000000000..e2ca96466 --- /dev/null +++ b/src/Subscription/Partitioner/TagPartitioner.php @@ -0,0 +1,50 @@ +header(TagsHeader::class)->tags; + } catch (HeaderNotFound) { + throw TagNotUnique::missing($this->prefix); + } + + $matching = array_values(array_filter( + $tags, + fn (string $tag): bool => str_starts_with($tag, $this->prefix), + )); + + if ($matching === []) { + throw TagNotUnique::missing($this->prefix); + } + + if (count($matching) > 1) { + throw TagNotUnique::ambiguous($this->prefix, $matching); + } + + return $matching[0]; + } +} diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index b3e5d8c1a..ce8cb9a07 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -27,6 +27,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Partitioner\BucketPartitioner; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepository; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; @@ -50,6 +52,7 @@ use function array_keys; use function array_map; use function array_values; +use function count; use function gc_collect_cycles; use function random_int; use function sort; @@ -542,6 +545,31 @@ public function testPartitionsOfARunOnceSubscriberFinish(): void } } + /** A fixed number of partitions, every profile stays in its bucket. */ + public function testBucketPartitioner(): void + { + $bucket = new BucketPartitioner(new StreamPartitioner(), 3); + ConfigurablePartitioner::$key = static fn (Message $message): string => $bucket($message); + + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(10); + + foreach ($ids as $id) { + $this->changeName(ProfileId::fromString($id), 'changed-' . $id); + } + + $this->runUntilFinished($engine); + + self::assertLessThanOrEqual(3, count($this->partitions())); + + foreach ($ids as $i => $id) { + self::assertSame(['name-' . $i, 'changed-' . $id], $projection->processed[$id]); + } + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { diff --git a/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php b/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php new file mode 100644 index 000000000..ded1f74ae --- /dev/null +++ b/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php @@ -0,0 +1,74 @@ +message('profile-' . $i)); + } + + self::assertSame(['0', '1', '2', '3'], $this->sorted(array_unique($keys))); + } + + public function testSameKeySameBucket(): void + { + $partitioner = new BucketPartitioner(new StreamPartitioner(), 16); + + self::assertSame($partitioner($this->message('profile-1')), $partitioner($this->message('profile-1'))); + } + + public function testNeedsAtLeastOneBucket(): void + { + $this->expectException(InvalidArgumentException::class); + + /** @phpstan-ignore argument.type, new.resultUnused */ + new BucketPartitioner(new StreamPartitioner(), 0); + } + + private function message(string $stream): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString('1'))) + ->withHeader(new StreamNameHeader($stream)); + } + + /** + * @param array $keys + * + * @return list + */ + private function sorted(array $keys): array + { + $keys = array_values($keys); + sort($keys); + + self::assertGreaterThan(0, count($keys)); + + return $keys; + } +} diff --git a/tests/Unit/Subscription/Partitioner/HashedPartitionerTest.php b/tests/Unit/Subscription/Partitioner/HashedPartitionerTest.php new file mode 100644 index 000000000..fd6774815 --- /dev/null +++ b/tests/Unit/Subscription/Partitioner/HashedPartitionerTest.php @@ -0,0 +1,44 @@ +message('profile-1'))); + self::assertNotSame($partitioner($this->message('ACME')), $partitioner($this->message('acme'))); + self::assertSame(32, strlen($partitioner($this->message(str_repeat('x', 1000))))); + } + + public function testOtherAlgorithm(): void + { + $partitioner = new HashedPartitioner(new StreamPartitioner(), 'sha256'); + + self::assertSame(hash('sha256', 'profile-1'), $partitioner($this->message('profile-1'))); + } + + private function message(string $stream): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString('1'))) + ->withHeader(new StreamNameHeader($stream)); + } +} diff --git a/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php b/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php new file mode 100644 index 000000000..092739301 --- /dev/null +++ b/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php @@ -0,0 +1,55 @@ +message(['student:1', 'course:42']))); + } + + public function testNoTagWithThePrefix(): void + { + $this->expectException(TagNotUnique::class); + + (new TagPartitioner('course:'))($this->message(['student:1'])); + } + + public function testNoTags(): void + { + $this->expectException(TagNotUnique::class); + + (new TagPartitioner('course:'))(Message::create(new ProfileVisited(ProfileId::fromString('1')))); + } + + public function testSeveralTagsWithThePrefix(): void + { + $this->expectException(TagNotUnique::class); + $this->expectExceptionMessage('course:1, course:2'); + + (new TagPartitioner('course:'))($this->message(['course:1', 'course:2'])); + } + + /** @param list $tags */ + private function message(array $tags): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString('1'))) + ->withHeader(new TagsHeader($tags)); + } +} From cbb7c0e0009b6b4e100391bb5ed2c328ba105aa6 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:41:50 +0200 Subject: [PATCH 39/57] Delete processed inbox rows by their key The inbox of a partition was cleared with a range delete up to its position. The range delete also locks the next row, often a message the main subscription has just routed, and MariaDB with snapshot isolation, the default since 11.6, fails on that row with "Record has changed since last read". The processed rows are now read first and deleted by their key. --- .../Store/DoctrineSubscriptionStore.php | 74 ++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index b84b1fd14..ce6186fcc 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -793,12 +793,82 @@ private function removeFromInbox(array $toIndex): void $types[] = ParameterType::INTEGER; } - $this->connection->executeStatement( - sprintf('DELETE FROM %s WHERE %s', $this->inboxTableName, implode(' OR ', $conditions)), + // a plain read, the rows up to the position already exist + /** @var list $rows */ + $rows = $this->connection->fetchAllNumeric( + sprintf('SELECT subscription_id, message_index FROM %s WHERE %s', $this->inboxTableName, implode(' OR ', $conditions)), $parameters, $types, ); + + $this->deleteFromInbox($rows); + } + } + + /** + * Deletes exactly these rows. A range delete would also lock the next row after the range, often a message the main + * subscription has just routed, and MariaDB with snapshot isolation (default since 11.6) fails on that row with + * "Record has changed since last read". + * + * @param list $rows + */ + private function deleteFromInbox(array $rows): void + { + /** @var array> $indices */ + $indices = []; + + foreach ($rows as [$subscriptionId, $messageIndex]) { + $indices[(string)$subscriptionId][] = (int)$messageIndex; } + + $conditions = []; + $parameters = []; + $types = []; + + foreach ($indices as $subscriptionId => $messageIndices) { + foreach (array_chunk($messageIndices, self::INBOX_CHUNK) as $indexChunk) { + $conditions[] = sprintf( + '(subscription_id = ? AND message_index IN (%s))', + implode(', ', array_fill(0, count($indexChunk), '?')), + ); + $parameters[] = (string)$subscriptionId; + $types[] = ParameterType::STRING; + + foreach ($indexChunk as $messageIndex) { + $parameters[] = $messageIndex; + $types[] = ParameterType::INTEGER; + } + + if (count($parameters) < self::INBOX_CHUNK) { + continue; + } + + $this->executeInboxDelete($conditions, $parameters, $types); + $conditions = []; + $parameters = []; + $types = []; + } + } + + if ($conditions === []) { + return; + } + + $this->executeInboxDelete($conditions, $parameters, $types); + } + + /** + * @param non-empty-list $conditions + * @param list $parameters + * @param list $types + */ + private function executeInboxDelete(array $conditions, array $parameters, array $types): void + { + $this->connection->executeStatement( + sprintf('DELETE FROM %s WHERE %s', $this->inboxTableName, implode(' OR ', $conditions)), + $parameters, + $types, + ); } /** From 22e37a9c1d2f52ff1b3ffb95a68e8a712c92c10f Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:42:34 +0200 Subject: [PATCH 40/57] Format the partition key example in the docs --- docs/subscription.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/subscription.md b/docs/subscription.md index 2aa55f9f8..61e8f97e2 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -329,7 +329,8 @@ use Patchlevel\EventSourcing\Attribute\Subscribe; final class ProfileProjector { #[Subscribe(ProfileCreated::class)] - public function onProfileCreated(ProfileCreated $event, #[PartitionKey] string $tenant): void + public function onProfileCreated(ProfileCreated $event, #[PartitionKey] + string $tenant,): void { // write into the database of the tenant } @@ -1870,6 +1871,7 @@ foreach ($subscriptions as $subscription) { ``` The ids of the criteria match the subscription id or the subscriber id, so a partitioned subscriber includes all its partitions. + ## Learn more * [How to use CLI commands](cli.md) From 5fb8362401cc681e1f2f023dc8f7adb3ac811fd1 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 13:50:01 +0200 Subject: [PATCH 41/57] Keep the partitioner in the subscriber metadata The partitioner was created from the attribute with reflection when it was needed, because a closure could not be cached with the metadata. Without closures the partitioner is a plain object and is stored in the metadata. --- .../AttributeSubscriberMetadataFactory.php | 6 +++++- src/Metadata/Subscriber/PartitionMetadata.php | 4 +++- .../Subscriber/MetadataSubscriberAccessor.php | 14 +------------- .../AttributeSubscriberMetadataFactoryTest.php | 8 ++++++-- .../Subscriber/MetadataSubscriberAccessorTest.php | 1 - 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php index 7fc99ba9a..8d8249462 100644 --- a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php @@ -244,7 +244,11 @@ public function metadata(string $subscriber): SubscriberMetadata } $partition = $partitionAttributes[0]->newInstance(); - $partitionMetadata = new PartitionMetadata($partition->maxConsecutiveFailures, $partition->removeIdleAfter); + $partitionMetadata = new PartitionMetadata( + $partition->partitioner, + $partition->maxConsecutiveFailures, + $partition->removeIdleAfter, + ); } $metadata = new SubscriberMetadata( diff --git a/src/Metadata/Subscriber/PartitionMetadata.php b/src/Metadata/Subscriber/PartitionMetadata.php index d50bc7126..ab092f002 100644 --- a/src/Metadata/Subscriber/PartitionMetadata.php +++ b/src/Metadata/Subscriber/PartitionMetadata.php @@ -4,7 +4,8 @@ namespace Patchlevel\EventSourcing\Metadata\Subscriber; -/** The partitioner itself is not part of the metadata, it is created from the attribute when it is needed. */ +use Patchlevel\EventSourcing\Subscription\Partitioner\Partitioner; + final class PartitionMetadata { /** @@ -12,6 +13,7 @@ final class PartitionMetadata * @param positive-int|null $removeIdleAfter seconds */ public function __construct( + public readonly Partitioner $partitioner, public readonly int $maxConsecutiveFailures = 5, public readonly int|null $removeIdleAfter = null, ) { diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php index a0e08b8cd..cab068c7b 100644 --- a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php @@ -5,13 +5,11 @@ namespace Patchlevel\EventSourcing\Subscription\Subscriber; use Closure; -use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Subscribe; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; use Patchlevel\EventSourcing\Subscription\Partitioner\Partitioner; -use ReflectionClass; use Throwable; use function array_key_exists; @@ -23,8 +21,6 @@ final class MetadataSubscriberAccessor /** @var array> */ private array $subscribeCache = []; - private Partitioner|null $partitioner = null; - /** @param T $subscriber */ public function __construct( private readonly object $subscriber, @@ -89,17 +85,9 @@ public function failedMethod(): Closure|null return $this->subscriber->$method(...); } - /** The partitioner of a partitioned subscriber, created from its #[Partition] attribute. */ public function partitioner(): Partitioner|null { - if ($this->metadata->partition === null) { - return null; - } - - return $this->partitioner ??= (new ReflectionClass($this->subscriber)) - ->getAttributes(Partition::class)[0] - ->newInstance() - ->partitioner; + return $this->metadata->partition?->partitioner; } /** @return list */ diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index 8266a1d15..0358b924a 100644 --- a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -50,6 +50,9 @@ use stdClass; use Symfony\Component\TypeInfo\Type; +use function serialize; +use function unserialize; + #[CoversClass(AttributeSubscriberMetadataFactory::class)] final class AttributeSubscriberMetadataFactoryTest extends TestCase { @@ -817,7 +820,7 @@ class { $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadata = $metadataFactory->metadata($subscriber::class); - self::assertEquals(new PartitionMetadata(5, null), $metadata->partition); + self::assertEquals(new PartitionMetadata(new StreamPartitioner(), 5, null), $metadata->partition); } public function testPartitionWithOptions(): void @@ -830,7 +833,8 @@ class { $metadataFactory = new AttributeSubscriberMetadataFactory(); $metadata = $metadataFactory->metadata($subscriber::class); - self::assertEquals(new PartitionMetadata(3, 60), $metadata->partition); + self::assertEquals(new PartitionMetadata(new StreamPartitioner(), 3, 60), $metadata->partition); + self::assertEquals($metadata, unserialize(serialize($metadata)), 'the metadata caches serialize it'); } public function testNoPartition(): void diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php index 71f1b1122..0eb20703d 100644 --- a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php +++ b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php @@ -155,7 +155,6 @@ class { self::assertInstanceOf(StreamPartitioner::class, $partitioner); self::assertSame('profile-1', $partitioner($this->streamMessage())); - self::assertSame($partitioner, $accessor->partitioner(), 'created once'); } public function testNoPartitioner(): void From 49f53a2d019f7d98bb878cc1b404bfc42233bd58 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 14:02:49 +0200 Subject: [PATCH 42/57] Load only the partitions that need attention in the status command The status command loaded every partition to show a summary, and run and boot loaded them to collect the subscriber ids. With one partition per aggregate that can be millions of subscriptions. SubscriptionEngineCriteria can now filter by partitions and status. The status command loads the main subscriptions and only the partitions that need attention, like the ones in error, all of them only with --partitions. Run and boot only load the main subscriptions. --- docs/cli.md | 5 +- src/Console/Command/SubscriptionCommand.php | 14 +- .../Command/SubscriptionStatusCommand.php | 77 +++++---- .../Engine/DefaultSubscriptionEngine.php | 2 + .../Engine/SubscriptionEngineCriteria.php | 8 +- .../Command/FakeSubscriptionEngine.php | 50 ++++++ .../Command/SubscriptionStatusCommandTest.php | 153 ++++++++++++------ 7 files changed, 221 insertions(+), 88 deletions(-) create mode 100644 tests/Unit/Console/Command/FakeSubscriptionEngine.php diff --git a/docs/cli.md b/docs/cli.md index d728ee162..398ed17df 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -240,8 +240,9 @@ If the subscription has an error, the command also prints the error message and ```bash bin/console event-sourcing:subscription:status profile_1 ``` -For a partitioned subscriber, the table shows a summary of its partitions. -Use `--partitions` to list them, and pass the id of a partition to show its details. +For a partitioned subscriber, the table shows the partitions that need attention, e.g. `error: 1`. +Active and finished partitions are not loaded, a subscriber can have many of them. +Use `--partitions` to load and list all of them, and pass the id of a partition to show its details. ```bash bin/console event-sourcing:subscription:status --partitions diff --git a/src/Console/Command/SubscriptionCommand.php b/src/Console/Command/SubscriptionCommand.php index 1611b2f40..6a7a32a92 100644 --- a/src/Console/Command/SubscriptionCommand.php +++ b/src/Console/Command/SubscriptionCommand.php @@ -12,8 +12,6 @@ use Symfony\Component\Console\Input\InputOption; use function array_map; -use function array_unique; -use function array_values; /** @interal */ abstract class SubscriptionCommand extends Command @@ -52,14 +50,16 @@ protected function subscriptionEngineCriteria(InputInterface $input): Subscripti protected function resolveCriteriaIntoCriteriaWithOnlyIds( SubscriptionEngineCriteria $criteria, ): SubscriptionEngineCriteria { - $subscriptions = $this->engine->subscriptions($criteria); + // a subscriber id includes all its partitions, so only the main subscriptions are needed + $subscriptions = $this->engine->subscriptions( + new SubscriptionEngineCriteria($criteria->ids, $criteria->groups, partitions: false), + ); - // a subscriber id includes all partitions, so the list stays as short as the number of subscribers return new SubscriptionEngineCriteria( - array_values(array_unique(array_map( - static fn ($subscription) => $subscription->subscriberId(), + array_map( + static fn ($subscription) => $subscription->id(), $subscriptions, - ))), + ), ); } } diff --git a/src/Console/Command/SubscriptionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php index e128652b0..b78dbac19 100644 --- a/src/Console/Command/SubscriptionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Console\InputHelper; use Patchlevel\EventSourcing\Console\OutputStyle; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; @@ -18,6 +19,7 @@ use function count; use function implode; +use function in_array; use function is_array; use function sprintf; @@ -28,6 +30,16 @@ )] final class SubscriptionStatusCommand extends SubscriptionCommand { + /** Partitions in these states are shown by default, active and finished ones only with --partitions. */ + private const ATTENTION = [ + Status::New, + Status::Booting, + Status::Paused, + Status::Detached, + Status::Error, + Status::Failed, + ]; + protected function configure(): void { parent::configure(); @@ -53,34 +65,38 @@ protected function execute(InputInterface $input, OutputInterface $output): int $id = InputHelper::nullableString($input->getArgument('id')); if ($id === null) { - $subscriptions = $this->engine->subscriptions($this->subscriptionEngineCriteria($input)); + $criteria = $this->subscriptionEngineCriteria($input); $showPartitions = InputHelper::bool($input->getOption('partitions')); + $subscriptions = $this->engine->subscriptions( + new SubscriptionEngineCriteria($criteria->ids, $criteria->groups, partitions: false), + ); + + // a subscriber can have many partitions, without the option only the ones that need attention are loaded + $partitionList = $subscriptions === [] ? [] : $this->engine->subscriptions(new SubscriptionEngineCriteria( + $criteria->ids, + $criteria->groups, + partitions: true, + status: $showPartitions ? null : self::ATTENTION, + )); + /** @var array> $partitions */ $partitions = []; - foreach ($subscriptions as $subscription) { - if (!$subscription->isPartition()) { - continue; - } - - $partitions[$subscription->subscriberId()][] = $subscription; + foreach ($partitionList as $partition) { + $partitions[$partition->subscriberId()][] = $partition; } $rows = []; foreach ($subscriptions as $subscription) { - if ($subscription->isPartition()) { - continue; - } - $rows[] = [ $subscription->id(), $subscription->group(), $subscription->runMode()->value, $subscription->position(), $subscription->status()->value, - self::partitionSummary($partitions[$subscription->id()] ?? []), + self::partitionSummary($partitions[$subscription->id()] ?? [], $showPartitions), $subscription->subscriptionError()?->errorMessage, ]; @@ -117,8 +133,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } + // the id of a subscriber also matches all its partitions, so the main subscription is looked up on its own $subscription = $this->findSubscription( - $this->engine->subscriptions(new SubscriptionEngineCriteria([$id])), + $this->engine->subscriptions(new SubscriptionEngineCriteria([$id], partitions: false)) + ?: $this->engine->subscriptions(new SubscriptionEngineCriteria([$id], partitions: true)), $id, ); @@ -165,37 +183,42 @@ private function displayError(OutputStyle $io, array $context): void } /** - * The number of partitions and how many of them are not active, e.g. "12 (error: 1)". + * With all partitions their number and how many of them need attention, e.g. "12 (error: 1)". + * Otherwise only the ones that need attention, e.g. "error: 1". * * @param list $partitions */ - private static function partitionSummary(array $partitions): string|null + private static function partitionSummary(array $partitions, bool $all): string|null { - if ($partitions === []) { - return null; - } - - $notActive = []; + $attention = []; foreach ($partitions as $partition) { - if ($partition->isActive()) { + if (!in_array($partition->status(), self::ATTENTION, true)) { continue; } $status = $partition->status()->value; - $notActive[$status] = ($notActive[$status] ?? 0) + 1; - } - - if ($notActive === []) { - return (string)count($partitions); + $attention[$status] = ($attention[$status] ?? 0) + 1; } $parts = []; - foreach ($notActive as $status => $count) { + foreach ($attention as $status => $count) { $parts[] = sprintf('%s: %d', $status, $count); } + if (!$all) { + return $parts === [] ? null : implode(', ', $parts); + } + + if ($partitions === []) { + return null; + } + + if ($parts === []) { + return (string)count($partitions); + } + return sprintf('%d (%s)', count($partitions), implode(', ', $parts)); } diff --git a/src/Subscription/Engine/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index 897e3fbff..713358f97 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -270,6 +270,8 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): new SubscriptionCriteria( ids: $criteria->ids, groups: $criteria->groups, + status: $criteria->status, + partitions: $criteria->partitions, ), ); } diff --git a/src/Subscription/Engine/SubscriptionEngineCriteria.php b/src/Subscription/Engine/SubscriptionEngineCriteria.php index 98a21b8fc..8941cbc11 100644 --- a/src/Subscription/Engine/SubscriptionEngineCriteria.php +++ b/src/Subscription/Engine/SubscriptionEngineCriteria.php @@ -4,16 +4,22 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; +use Patchlevel\EventSourcing\Subscription\Status; + /** @immutable */ final class SubscriptionEngineCriteria { /** - * @param list|null $ids + * @param list|null $ids matches the subscription id or the subscriber id * @param list|null $groups + * @param bool|null $partitions true only matches partitions, false only the main subscriptions + * @param list|null $status */ public function __construct( public readonly array|null $ids = null, public readonly array|null $groups = null, + public readonly bool|null $partitions = null, + public readonly array|null $status = null, ) { } } diff --git a/tests/Unit/Console/Command/FakeSubscriptionEngine.php b/tests/Unit/Console/Command/FakeSubscriptionEngine.php new file mode 100644 index 000000000..a917a89ee --- /dev/null +++ b/tests/Unit/Console/Command/FakeSubscriptionEngine.php @@ -0,0 +1,50 @@ + */ + public array $criteria = []; + + /** @param list $subscriptions */ + public function __construct( + private readonly array $subscriptions, + ) { + } + + public function execute(Command $command): Result + { + return new Result(); + } + + /** @return list */ + public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array + { + $criteria ??= new SubscriptionEngineCriteria(); + $this->criteria[] = $criteria; + + return array_values(array_filter( + $this->subscriptions, + static fn (Subscription $subscription): bool => ($criteria->ids === null + || in_array($subscription->id(), $criteria->ids, true) + || in_array($subscription->subscriberId(), $criteria->ids, true)) + && ($criteria->groups === null || in_array($subscription->group(), $criteria->groups, true)) + && ($criteria->status === null || in_array($subscription->status(), $criteria->status, true)) + && ($criteria->partitions === null || $subscription->isPartition() === $criteria->partitions), + )); + } +} diff --git a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php index e34a97ef0..2c15c4aaf 100644 --- a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -5,7 +5,6 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Console\Command; use Patchlevel\EventSourcing\Console\Command\SubscriptionStatusCommand; -use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; @@ -22,15 +21,10 @@ final class SubscriptionStatusCommandTest extends TestCase { public function testStatusList(): void { - $engine = $this->createMock(SubscriptionEngine::class); - $engine - ->expects($this->once()) - ->method('subscriptions') - ->with(new SubscriptionEngineCriteria()) - ->willReturn([ - new Subscription('foo'), - new Subscription('bar', 'other', RunMode::Once, Status::Active, 42), - ]); + $engine = $this->engine([ + new Subscription('foo'), + new Subscription('bar', 'other', RunMode::Once, Status::Active, 42), + ]); $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); $commandTester->execute([]); @@ -46,14 +40,10 @@ public function testStatusList(): void public function testStatusListFiltered(): void { - $engine = $this->createMock(SubscriptionEngine::class); - $engine - ->expects($this->once()) - ->method('subscriptions') - ->with(new SubscriptionEngineCriteria(['foo', 'bar'], ['projector'])) - ->willReturn([ - new Subscription('foo', 'projector', RunMode::FromBeginning, Status::Active, 42), - ]); + $engine = $this->engine([ + new Subscription('foo', 'projector', RunMode::FromBeginning, Status::Active, 42), + new Subscription('baz', 'projector', RunMode::FromBeginning, Status::Active, 7), + ]); $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); $commandTester->execute([ @@ -66,18 +56,65 @@ public function testStatusListFiltered(): void self::assertSame(0, $commandTester->getStatusCode()); self::assertStringContainsString('foo', $display); self::assertStringContainsString('projector', $display); + self::assertStringNotContainsString('baz', $display); + self::assertEquals( + new SubscriptionEngineCriteria(['foo', 'bar'], ['projector'], partitions: false), + $engine->criteria[0], + ); + } + + public function testOnlyPartitionsThatNeedAttentionAreLoaded(): void + { + $main = new Subscription('profile', status: Status::Active, position: 10); + $broken = Subscription::partition($main, 'broken', 3); + $broken->error('database down'); + + $engine = $this->engine([ + $main, + Subscription::partition($main, 'a', 5), + Subscription::partition($main, 'b', 6), + $broken, + ]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute([]); + + $display = $commandTester->getDisplay(); + + self::assertStringContainsString('error: 1', $display); + self::assertStringNotContainsString('3 (error: 1)', $display, 'the total needs every partition'); + self::assertFalse($engine->criteria[1]->status === null, 'active partitions are not loaded'); + self::assertNotContains(Status::Active, $engine->criteria[1]->status ?? []); + } + + public function testListAllPartitions(): void + { + $main = new Subscription('profile', status: Status::Active, position: 10); + $broken = Subscription::partition($main, 'broken', 3); + $broken->error('database down'); + + $engine = $this->engine([ + $main, + Subscription::partition($main, 'a', 5), + Subscription::partition($main, 'b', 6), + $broken, + ]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute(['--partitions' => true]); + + $display = $commandTester->getDisplay(); + + self::assertStringContainsString('3 (error: 1)', $display); + self::assertStringContainsString('└ a', $display); + self::assertStringContainsString('└ broken', $display); + self::assertNull($engine->criteria[1]->status); } public function testStatusDetail(): void { - $engine = $this->createMock(SubscriptionEngine::class); - $engine - ->expects($this->once()) - ->method('subscriptions') - ->with(new SubscriptionEngineCriteria(['foo'])) - ->willReturn([ - new Subscription('foo', 'default', RunMode::FromBeginning, Status::Active, 42), - ]); + $main = new Subscription('foo', 'default', RunMode::FromBeginning, Status::Active, 42); + $engine = $this->engine([$main, Subscription::partition($main, 'a', 1)]); $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); $commandTester->execute(['id' => 'foo']); @@ -88,28 +125,39 @@ public function testStatusDetail(): void self::assertStringContainsString('foo', $display); self::assertStringContainsString('active', $display); self::assertStringContainsString('42', $display); + self::assertEquals([new SubscriptionEngineCriteria(['foo'], partitions: false)], $engine->criteria, 'partitions are not loaded'); + } + + public function testStatusDetailOfAPartition(): void + { + $main = new Subscription('foo', 'default', RunMode::FromBeginning, Status::Active, 42); + $engine = $this->engine([$main, Subscription::partition($main, 'tenant-a', 7)]); + + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester->execute(['id' => 'foo#tenant-a']); + + $display = $commandTester->getDisplay(); + + self::assertSame(0, $commandTester->getStatusCode()); + self::assertStringContainsString('foo#tenant-a', $display); + self::assertStringContainsString('7', $display); } public function testStatusDetailWithError(): void { - $engine = $this->createMock(SubscriptionEngine::class); - $engine - ->expects($this->once()) - ->method('subscriptions') - ->with(new SubscriptionEngineCriteria(['foo'])) - ->willReturn([ - new Subscription( - 'foo', - 'default', - RunMode::FromBeginning, - Status::Error, - 42, - SubscriptionError::fromThrowable( - Status::Active, - new RuntimeException('something went wrong'), - ), + $engine = $this->engine([ + new Subscription( + 'foo', + 'default', + RunMode::FromBeginning, + Status::Error, + 42, + SubscriptionError::fromThrowable( + Status::Active, + new RuntimeException('something went wrong'), ), - ]); + ), + ]); $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); $commandTester->execute(['id' => 'foo']); @@ -124,17 +172,20 @@ public function testStatusDetailWithError(): void public function testStatusNotFound(): void { - $engine = $this->createMock(SubscriptionEngine::class); - $engine - ->expects($this->once()) - ->method('subscriptions') - ->with(new SubscriptionEngineCriteria(['bar'])) - ->willReturn([]); - - $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); + $commandTester = new CommandTester(new SubscriptionStatusCommand($this->engine([]))); $this->expectException(SubscriptionNotFound::class); $commandTester->execute(['id' => 'bar']); } + + /** + * Filters like the subscription store and records every criteria it gets. + * + * @param list $subscriptions + */ + private function engine(array $subscriptions): FakeSubscriptionEngine + { + return new FakeSubscriptionEngine($subscriptions); + } } From fe390aa237b559550ae44518c433dad25212f064 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 14:21:42 +0200 Subject: [PATCH 43/57] Count subscriptions per status with stats() SubscriptionStore and SubscriptionEngine get a stats() method that counts the subscriptions matching a criteria per status, without loading them. The status command uses it to show the number of partitions of a subscriber and how many of them are not active again, it only loads the partitions to list them with --partitions. --- docs/UPGRADE-4.0.md | 6 ++ docs/cli.md | 6 +- docs/subscription.md | 14 ++++ .../Command/SubscriptionStatusCommand.php | 68 ++++++------------- .../Engine/CatchUpSubscriptionEngine.php | 6 ++ .../Engine/DefaultSubscriptionEngine.php | 15 ++++ .../Engine/SubscriptionEngine.php | 4 ++ .../Engine/SubscriptionManager.php | 6 ++ .../Engine/ThrowOnErrorSubscriptionEngine.php | 6 ++ .../Store/DoctrineSubscriptionStore.php | 21 ++++++ .../Store/InMemorySubscriptionStore.php | 11 +++ src/Subscription/Store/SubscriptionStats.php | 29 ++++++++ src/Subscription/Store/SubscriptionStore.php | 3 + .../DoctrineSubscriptionStoreTest.php | 24 +++++++ .../PartitionedSubscriptionTest.php | 5 ++ .../Subscription/PlainSubscriptionStore.php | 6 ++ .../Command/FakeSubscriptionEngine.php | 24 +++++++ .../Command/SubscriptionStatusCommandTest.php | 15 ++-- .../Subscription/DummySubscriptionStore.php | 6 ++ .../Engine/CatchUpSubscriptionEngineTest.php | 14 ++++ .../Listener/RemovePartitionsListenerTest.php | 6 ++ .../ThrowOnErrorSubscriptionEngineTest.php | 14 ++++ .../Store/InMemorySubscriptionStoreTest.php | 22 ++++++ 23 files changed, 276 insertions(+), 55 deletions(-) create mode 100644 src/Subscription/Store/SubscriptionStats.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 535215d82..24a22dbf0 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -432,12 +432,16 @@ several subscriptions at once. Every custom store has to implement: ```php use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; interface SubscriptionStore { // ... get/find ... + /** Count the matching subscriptions per status, without loading them. */ + public function stats(SubscriptionCriteria $criteria): SubscriptionStats; + /** * Claim the subscriptions via row locks (SKIP LOCKED). Leave out the ones that are held * by another worker or no longer match the criteria. @@ -467,6 +471,8 @@ interface SubscriptionStore `find()` no longer locks the matched rows: it is now a plain, unlocked snapshot read. The locking happens per subscription inside `claim()`. +The `SubscriptionEngine` interface has the new method `stats()` as well, a custom engine or decorator passes it on. + The `DoctrineSubscriptionStore` loads the error context and the cleanup tasks of the subscriptions from `find()` lazily. Subscriptions that have one of them are native lazy objects, which `var_dump()` and `assertEquals()` show without these fields until they are accessed. diff --git a/docs/cli.md b/docs/cli.md index 398ed17df..6715b3c10 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -240,9 +240,9 @@ If the subscription has an error, the command also prints the error message and ```bash bin/console event-sourcing:subscription:status profile_1 ``` -For a partitioned subscriber, the table shows the partitions that need attention, e.g. `error: 1`. -Active and finished partitions are not loaded, a subscriber can have many of them. -Use `--partitions` to load and list all of them, and pass the id of a partition to show its details. +For a partitioned subscriber, the table shows the number of partitions and how many are not active, +e.g. `1204 (error: 2, booting: 5)`. The partitions are counted, not loaded. +Use `--partitions` to list them, and pass the id of a partition to show its details. ```bash bin/console event-sourcing:subscription:status --partitions diff --git a/docs/subscription.md b/docs/subscription.md index 61e8f97e2..f74f747e0 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1872,6 +1872,20 @@ foreach ($subscriptions as $subscription) { The ids of the criteria match the subscription id or the subscriber id, so a partitioned subscriber includes all its partitions. +To count the subscriptions per status without loading them, use the `stats` method. + +```php +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Status; + +/** @var SubscriptionEngine $subscriptionEngine */ +$stats = $subscriptionEngine->stats(new SubscriptionEngineCriteria(['profile_1'], partitions: true)); + +echo $stats->total(); +echo $stats->count(Status::Error); +``` + ## Learn more * [How to use CLI commands](cli.md) diff --git a/src/Console/Command/SubscriptionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php index b78dbac19..6d6d38b9e 100644 --- a/src/Console/Command/SubscriptionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Symfony\Component\Console\Attribute\AsCommand; @@ -17,9 +18,7 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use function count; use function implode; -use function in_array; use function is_array; use function sprintf; @@ -30,16 +29,6 @@ )] final class SubscriptionStatusCommand extends SubscriptionCommand { - /** Partitions in these states are shown by default, active and finished ones only with --partitions. */ - private const ATTENTION = [ - Status::New, - Status::Booting, - Status::Paused, - Status::Detached, - Status::Error, - Status::Failed, - ]; - protected function configure(): void { parent::configure(); @@ -72,13 +61,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int new SubscriptionEngineCriteria($criteria->ids, $criteria->groups, partitions: false), ); - // a subscriber can have many partitions, without the option only the ones that need attention are loaded - $partitionList = $subscriptions === [] ? [] : $this->engine->subscriptions(new SubscriptionEngineCriteria( - $criteria->ids, - $criteria->groups, - partitions: true, - status: $showPartitions ? null : self::ATTENTION, - )); + // a subscriber can have many partitions, they are only loaded to list them + $partitionList = $showPartitions && $subscriptions !== [] + ? $this->engine->subscriptions( + new SubscriptionEngineCriteria($criteria->ids, $criteria->groups, partitions: true), + ) + : []; /** @var array> $partitions */ $partitions = []; @@ -96,7 +84,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $subscription->runMode()->value, $subscription->position(), $subscription->status()->value, - self::partitionSummary($partitions[$subscription->id()] ?? [], $showPartitions), + self::partitionSummary($this->engine->stats( + new SubscriptionEngineCriteria([$subscription->id()], partitions: true), + )), $subscription->subscriptionError()?->errorMessage, ]; @@ -182,44 +172,30 @@ private function displayError(OutputStyle $io, array $context): void } } - /** - * With all partitions their number and how many of them need attention, e.g. "12 (error: 1)". - * Otherwise only the ones that need attention, e.g. "error: 1". - * - * @param list $partitions - */ - private static function partitionSummary(array $partitions, bool $all): string|null + /** The number of partitions and how many of them are not active, e.g. "1204 (error: 2, booting: 5)". */ + private static function partitionSummary(SubscriptionStats $stats): string|null { - $attention = []; - - foreach ($partitions as $partition) { - if (!in_array($partition->status(), self::ATTENTION, true)) { - continue; - } + $total = $stats->total(); - $status = $partition->status()->value; - $attention[$status] = ($attention[$status] ?? 0) + 1; + if ($total === 0) { + return null; } $parts = []; - foreach ($attention as $status => $count) { - $parts[] = sprintf('%s: %d', $status, $count); - } - - if (!$all) { - return $parts === [] ? null : implode(', ', $parts); - } + foreach ($stats->status as $status => $count) { + if ($status === Status::Active->value) { + continue; + } - if ($partitions === []) { - return null; + $parts[] = sprintf('%s: %d', $status, $count); } if ($parts === []) { - return (string)count($partitions); + return (string)$total; } - return sprintf('%d (%s)', count($partitions), implode(', ', $parts)); + return sprintf('%d (%s)', $total, implode(', ', $parts)); } /** @param list $subscriptions */ diff --git a/src/Subscription/Engine/CatchUpSubscriptionEngine.php b/src/Subscription/Engine/CatchUpSubscriptionEngine.php index 6f6ee5729..d26d85939 100644 --- a/src/Subscription/Engine/CatchUpSubscriptionEngine.php +++ b/src/Subscription/Engine/CatchUpSubscriptionEngine.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; use function array_merge; @@ -48,6 +49,11 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): return $this->parent->subscriptions($criteria); } + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats + { + return $this->parent->stats($criteria); + } + private function mergeResult(ProcessedResult ...$results): ProcessedResult { $processedMessages = 0; diff --git a/src/Subscription/Engine/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index 713358f97..e8b955920 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -38,6 +38,7 @@ use Patchlevel\EventSourcing\Subscription\RetryStrategy\NoRetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\ArgumentResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\BatchArgumentResolver; @@ -275,4 +276,18 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): ), ); } + + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats + { + $criteria ??= new SubscriptionEngineCriteria(); + + return $this->subscriptionManager->stats( + new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: $criteria->status, + partitions: $criteria->partitions, + ), + ); + } } diff --git a/src/Subscription/Engine/SubscriptionEngine.php b/src/Subscription/Engine/SubscriptionEngine.php index b081695f3..2fae1444c 100644 --- a/src/Subscription/Engine/SubscriptionEngine.php +++ b/src/Subscription/Engine/SubscriptionEngine.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; interface SubscriptionEngine @@ -14,4 +15,7 @@ public function execute(Command $command): Result; /** @return list */ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): array; + + /** Counts the matching subscriptions per status, without loading them. */ + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats; } diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 772d78ed4..85ef2bbdb 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -7,6 +7,7 @@ use Closure; use Patchlevel\EventSourcing\Subscription\Store\PartitionInboxStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\TransactionCommitNotPossible; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -301,6 +302,11 @@ public function find(SubscriptionCriteria $criteria): array return $this->subscriptionStore->find($criteria); } + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + return $this->subscriptionStore->stats($criteria); + } + public function add(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { diff --git a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php index 054e8a195..a4e7c1a40 100644 --- a/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php +++ b/src/Subscription/Engine/ThrowOnErrorSubscriptionEngine.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; final class ThrowOnErrorSubscriptionEngine implements SubscriptionEngine @@ -31,4 +32,9 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): { return $this->parent->subscriptions($criteria); } + + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats + { + return $this->parent->stats($criteria); + } } diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index ce6186fcc..55ae96c8f 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -251,6 +251,27 @@ private function fetch(SubscriptionCriteria|null $criteria, bool|null $pendingPa ); } + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + $qb = $this->connection->createQueryBuilder() + ->select('status', 'COUNT(*) AS number') + ->from($this->tableName) + ->groupBy('status'); + + $this->applyCriteria($qb, $criteria); + + $status = []; + + /** @var list $rows */ + $rows = $qb->fetchAllAssociative(); + + foreach ($rows as $row) { + $status[Status::from($row['status'])->value] = (int)$row['number']; + } + + return new SubscriptionStats($status); + } + /** * @param non-empty-list $ids * diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index b1ef234b2..009ecd754 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -62,6 +62,17 @@ public function find(SubscriptionCriteria|null $criteria = null): array ); } + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + $status = []; + + foreach ($this->find($criteria) as $subscription) { + $status[$subscription->status()->value] = ($status[$subscription->status()->value] ?? 0) + 1; + } + + return new SubscriptionStats($status); + } + /** * @param non-empty-list $ids * diff --git a/src/Subscription/Store/SubscriptionStats.php b/src/Subscription/Store/SubscriptionStats.php new file mode 100644 index 000000000..34fee5689 --- /dev/null +++ b/src/Subscription/Store/SubscriptionStats.php @@ -0,0 +1,29 @@ +, int> $status number of subscriptions per status, statuses without any are left out */ + public function __construct( + public readonly array $status = [], + ) { + } + + public function total(): int + { + return array_sum($this->status); + } + + public function count(Status $status): int + { + return $this->status[$status->value] ?? 0; + } +} diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index f355289e7..e999e0254 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -19,6 +19,9 @@ public function get(string $subscriptionId): Subscription; */ public function find(SubscriptionCriteria|null $criteria = null): array; + /** Counts the matching subscriptions per status, without loading them. */ + public function stats(SubscriptionCriteria $criteria): SubscriptionStats; + /** * Claims the subscriptions via row locks (SKIP LOCKED), leaving out the ones that are held by another worker or no * longer match the criteria. diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index ffe9ad6b8..f8b2165db 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -1259,6 +1259,30 @@ public function testRemoveIdlePartitions(): void self::assertSame(0, $store->removeIdlePartitions('profile', 60, 10), 'nothing left to remove'); } + public function testStats(): void + { + $store = $this->createStore(); + + $main = new Subscription('profile', status: Status::Active, position: 10); + $broken = Subscription::partition($main, 'broken', 3); + $broken->error('database down'); + + $store->add( + $main, + Subscription::partition($main, 'a', 5), + Subscription::partition($main, 'b', 6), + $broken, + new Subscription('other', status: Status::Paused), + ); + + $stats = $store->stats(new SubscriptionCriteria(ids: ['profile'], partitions: true)); + + self::assertEquals(['active' => 2, 'error' => 1], $stats->status); + self::assertSame(3, $stats->total()); + self::assertSame([], $store->stats(new SubscriptionCriteria(ids: ['unknown']))->status); + self::assertSame(1, $store->stats(new SubscriptionCriteria(status: [Status::Paused]))->total()); + } + public function testClaimAll(): void { $store = $this->createStore(); diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index ce8cb9a07..d16c77ecb 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -27,6 +27,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Partitioner\BucketPartitioner; use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\Repository\RunSubscriptionEngineRepository; @@ -112,6 +113,10 @@ public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void } self::assertSame(3, $projection->handled, 'only the partitions call the subscriber, every message once'); + self::assertSame( + ['active' => 3], + $engine->stats(new SubscriptionEngineCriteria([self::MAIN], partitions: true))->status, + ); foreach ($this->partitions() as $partition) { self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10), 'processed messages leave the inbox'); diff --git a/tests/Integration/Subscription/PlainSubscriptionStore.php b/tests/Integration/Subscription/PlainSubscriptionStore.php index 2589ce3db..4f45fba79 100644 --- a/tests/Integration/Subscription/PlainSubscriptionStore.php +++ b/tests/Integration/Subscription/PlainSubscriptionStore.php @@ -6,6 +6,7 @@ use Closure; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -22,6 +23,11 @@ public function get(string $subscriptionId): Subscription return $this->store->get($subscriptionId); } + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + return $this->store->stats($criteria); + } + /** @return list */ public function find(SubscriptionCriteria|null $criteria = null): array { diff --git a/tests/Unit/Console/Command/FakeSubscriptionEngine.php b/tests/Unit/Console/Command/FakeSubscriptionEngine.php index a917a89ee..f964c69fb 100644 --- a/tests/Unit/Console/Command/FakeSubscriptionEngine.php +++ b/tests/Unit/Console/Command/FakeSubscriptionEngine.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; use function array_filter; @@ -37,6 +38,29 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): $criteria ??= new SubscriptionEngineCriteria(); $this->criteria[] = $criteria; + return $this->filter($criteria); + } + + /** @var list */ + public array $statsCriteria = []; + + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats + { + $criteria ??= new SubscriptionEngineCriteria(); + $this->statsCriteria[] = $criteria; + + $status = []; + + foreach ($this->filter($criteria) as $subscription) { + $status[$subscription->status()->value] = ($status[$subscription->status()->value] ?? 0) + 1; + } + + return new SubscriptionStats($status); + } + + /** @return list */ + private function filter(SubscriptionEngineCriteria $criteria): array + { return array_values(array_filter( $this->subscriptions, static fn (Subscription $subscription): bool => ($criteria->ids === null diff --git a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php index 2c15c4aaf..6806d9d9c 100644 --- a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -63,7 +63,7 @@ public function testStatusListFiltered(): void ); } - public function testOnlyPartitionsThatNeedAttentionAreLoaded(): void + public function testPartitionSummaryWithoutLoadingThePartitions(): void { $main = new Subscription('profile', status: Status::Active, position: 10); $broken = Subscription::partition($main, 'broken', 3); @@ -74,6 +74,7 @@ public function testOnlyPartitionsThatNeedAttentionAreLoaded(): void Subscription::partition($main, 'a', 5), Subscription::partition($main, 'b', 6), $broken, + new Subscription('plain', status: Status::Active, position: 3), ]); $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); @@ -81,10 +82,13 @@ public function testOnlyPartitionsThatNeedAttentionAreLoaded(): void $display = $commandTester->getDisplay(); - self::assertStringContainsString('error: 1', $display); - self::assertStringNotContainsString('3 (error: 1)', $display, 'the total needs every partition'); - self::assertFalse($engine->criteria[1]->status === null, 'active partitions are not loaded'); - self::assertNotContains(Status::Active, $engine->criteria[1]->status ?? []); + self::assertStringContainsString('3 (error: 1)', $display); + self::assertStringNotContainsString('└', $display, 'the partitions are only listed with --partitions'); + self::assertEquals( + [new SubscriptionEngineCriteria(partitions: false)], + $engine->criteria, + 'only the main subscriptions are loaded', + ); } public function testListAllPartitions(): void @@ -108,7 +112,6 @@ public function testListAllPartitions(): void self::assertStringContainsString('3 (error: 1)', $display); self::assertStringContainsString('└ a', $display); self::assertStringContainsString('└ broken', $display); - self::assertNull($engine->criteria[1]->status); } public function testStatusDetail(): void diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 5a74a806f..841a1a411 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\PartitionInboxStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; use PHPUnit\Framework\Assert; use Psr\Clock\ClockInterface; @@ -45,6 +46,11 @@ public function get(string $subscriptionId): Subscription return $this->parentStore->get($subscriptionId); } + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + return $this->parentStore->stats($criteria); + } + /** @return list */ public function find(SubscriptionCriteria|null $criteria = null): array { diff --git a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php index 56d14b43a..319fbf296 100644 --- a/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/CatchUpSubscriptionEngineTest.php @@ -11,6 +11,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -73,6 +74,19 @@ public function testRunLimit(): void self::assertEquals(new ProcessedResult(2), $result); } + public function testStats(): void + { + $parent = $this->createMock(SubscriptionEngine::class); + + $engine = new CatchUpSubscriptionEngine($parent); + $criteria = new SubscriptionEngineCriteria(['foo']); + $stats = new SubscriptionStats(['active' => 2]); + + $parent->expects($this->once())->method('stats')->with($criteria)->willReturn($stats); + + self::assertSame($stats, $engine->stats($criteria)); + } + public function testSubscriptions(): void { $parent = $this->createMock(SubscriptionEngine::class); diff --git a/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php index 5cb41c714..f3b8ac6b7 100644 --- a/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php @@ -11,6 +11,7 @@ use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Subscription; use PHPUnit\Framework\Attributes\CoversClass; @@ -60,6 +61,11 @@ public function find(SubscriptionCriteria|null $criteria = null): array return $this->inner->find(new SubscriptionCriteria(ids: $criteria?->ids)); } + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + return $this->inner->stats($criteria); + } + /** * @param non-empty-list $ids * diff --git a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php index b42a6b616..fd9367eac 100644 --- a/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php +++ b/tests/Unit/Subscription/Engine/ThrowOnErrorSubscriptionEngineTest.php @@ -11,6 +11,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\Engine\ThrowOnErrorSubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; use RuntimeException; @@ -53,6 +54,19 @@ public function testRunError(): void $engine->execute($command); } + public function testStats(): void + { + $parent = $this->createMock(SubscriptionEngine::class); + + $engine = new ThrowOnErrorSubscriptionEngine($parent); + $criteria = new SubscriptionEngineCriteria(['foo']); + $stats = new SubscriptionStats(['active' => 2]); + + $parent->expects($this->once())->method('stats')->with($criteria)->willReturn($stats); + + self::assertSame($stats, $engine->stats($criteria)); + } + public function testSubscriptions(): void { $parent = $this->createMock(SubscriptionEngine::class); diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index 94da08f0b..abf400235 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -97,6 +97,28 @@ public function testRemove(): void self::assertEquals([], $store->find()); } + public function testStats(): void + { + $main = new Subscription('profile', status: Status::Active, position: 10); + $broken = Subscription::partition($main, 'broken', 3); + $broken->error('database down'); + + $store = new InMemorySubscriptionStore([ + $main, + Subscription::partition($main, 'a', 5), + $broken, + new Subscription('other', status: Status::Paused), + ]); + + $stats = $store->stats(new SubscriptionCriteria(ids: ['profile'], partitions: true)); + + self::assertSame(['active' => 1, 'error' => 1], $stats->status); + self::assertSame(2, $stats->total()); + self::assertSame(1, $stats->count(Status::Error)); + self::assertSame(0, $stats->count(Status::Failed)); + self::assertSame(['active' => 2, 'error' => 1, 'paused' => 1], $store->stats(new SubscriptionCriteria())->status); + } + public function testFind(): void { $subscription1 = new Subscription('1'); From b7cce29952101b4d6908b0e8c648ae3242adbd26 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 14:24:49 +0200 Subject: [PATCH 44/57] Rely on the positive-int type for the number of buckets --- src/Subscription/Partitioner/BucketPartitioner.php | 6 ------ .../Subscription/Partitioner/BucketPartitionerTest.php | 9 --------- 2 files changed, 15 deletions(-) diff --git a/src/Subscription/Partitioner/BucketPartitioner.php b/src/Subscription/Partitioner/BucketPartitioner.php index 5fe355557..6703d72b1 100644 --- a/src/Subscription/Partitioner/BucketPartitioner.php +++ b/src/Subscription/Partitioner/BucketPartitioner.php @@ -4,11 +4,9 @@ namespace Patchlevel\EventSourcing\Subscription\Partitioner; -use InvalidArgumentException; use Patchlevel\EventSourcing\Message\Message; use function crc32; -use function sprintf; /** * Spreads the keys of another partitioner over a fixed number of partitions, e.g. @@ -21,10 +19,6 @@ public function __construct( private readonly Partitioner $partitioner, private readonly int $buckets, ) { - // @phpstan-ignore smaller.alwaysFalse (the attribute is not checked by static analysis) - if ($buckets < 1) { - throw new InvalidArgumentException(sprintf('The number of buckets must be positive, %d given.', $buckets)); - } } public function __invoke(Message $message): string diff --git a/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php b/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php index ded1f74ae..da29e788c 100644 --- a/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php +++ b/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php @@ -4,7 +4,6 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Partitioner; -use InvalidArgumentException; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Subscription\Partitioner\BucketPartitioner; @@ -43,14 +42,6 @@ public function testSameKeySameBucket(): void self::assertSame($partitioner($this->message('profile-1')), $partitioner($this->message('profile-1'))); } - public function testNeedsAtLeastOneBucket(): void - { - $this->expectException(InvalidArgumentException::class); - - /** @phpstan-ignore argument.type, new.resultUnused */ - new BucketPartitioner(new StreamPartitioner(), 0); - } - private function message(string $stream): Message { return Message::create(new ProfileVisited(ProfileId::fromString('1'))) From 1c84d0addcb16d5db1123270471d1fbf61a3ace1 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:01:38 +0200 Subject: [PATCH 45/57] Wait for locked subscriptions in commands Commands like pause, reactivate, remove, teardown, setup and refresh claimed with SKIP LOCKED, so a subscription a worker was processing at that moment was silently left out, e.g. a partition stayed active after pausing its subscriber. claim() now takes $wait, these commands and the circuit breaker wait for the lock instead. Detaching and refreshing no longer update subscriptions without a lock. Processing still skips locked subscriptions. --- docs/UPGRADE-4.0.md | 7 +- .../Engine/Handler/PauseHandler.php | 1 + .../Engine/Handler/ReactivateHandler.php | 1 + .../Engine/Handler/RefreshHandler.php | 162 ++++++++++-------- .../Engine/Handler/RemoveHandler.php | 1 + .../Engine/Handler/SetupHandler.php | 1 + .../Engine/Handler/TeardownHandler.php | 1 + .../Engine/Listener/DetachListener.php | 59 ++++--- .../PartitionCircuitBreakerSubscriber.php | 3 + .../Engine/SubscriptionManager.php | 34 +++- .../Store/DoctrineSubscriptionStore.php | 4 +- .../Store/InMemorySubscriptionStore.php | 2 +- src/Subscription/Store/SubscriptionStore.php | 7 +- .../Subscription/PlainSubscriptionStore.php | 4 +- .../Subscription/WaitingClaimTest.php | 153 +++++++++++++++++ .../Integration/Worker/lock-subscription.php | 31 ++++ .../Subscription/DummySubscriptionStore.php | 4 +- .../Listener/RemovePartitionsListenerTest.php | 4 +- 18 files changed, 369 insertions(+), 110 deletions(-) create mode 100644 tests/Integration/Subscription/WaitingClaimTest.php create mode 100644 tests/Integration/Worker/lock-subscription.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 24a22dbf0..b7ac0acb9 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -443,14 +443,15 @@ interface SubscriptionStore public function stats(SubscriptionCriteria $criteria): SubscriptionStats; /** - * Claim the subscriptions via row locks (SKIP LOCKED). Leave out the ones that are held - * by another worker or no longer match the criteria. + * Claim the subscriptions via row locks. Leave out the ones that no longer match the criteria, + * and the ones held by another worker (SKIP LOCKED). With $wait, wait for them instead, + * commands like pause or remove have to reach every subscription. * * @param non-empty-list $ids * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array; + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array; public function add(Subscription ...$subscriptions): void; diff --git a/src/Subscription/Engine/Handler/PauseHandler.php b/src/Subscription/Engine/Handler/PauseHandler.php index 867c399c4..105bd30d0 100644 --- a/src/Subscription/Engine/Handler/PauseHandler.php +++ b/src/Subscription/Engine/Handler/PauseHandler.php @@ -65,6 +65,7 @@ function (array $subscriptions): Result { static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), $subscriptions, )), + wait: true, ); return Result::merge($results); diff --git a/src/Subscription/Engine/Handler/ReactivateHandler.php b/src/Subscription/Engine/Handler/ReactivateHandler.php index 87ae0dc98..fb661f09d 100644 --- a/src/Subscription/Engine/Handler/ReactivateHandler.php +++ b/src/Subscription/Engine/Handler/ReactivateHandler.php @@ -63,6 +63,7 @@ function (array $subscriptions): Result { static fn (Subscription $subscription): Error => new Error($subscription->id(), $e->getMessage(), $e), $subscriptions, )), + wait: true, ); return Result::merge($results); diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php index 65760392b..b9f4e3aa9 100644 --- a/src/Subscription/Engine/Handler/RefreshHandler.php +++ b/src/Subscription/Engine/Handler/RefreshHandler.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Handler; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\Command\Command; use Patchlevel\EventSourcing\Subscription\Engine\Command\Refresh; use Patchlevel\EventSourcing\Subscription\Engine\Result; @@ -11,6 +12,7 @@ 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 function array_values; @@ -23,6 +25,8 @@ */ final class RefreshHandler implements Handler { + private const PARTITIONS_PER_CLAIM = 100; + public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, @@ -32,76 +36,100 @@ public function __construct( 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->subscriberId()); - - 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(), - ), - ); - } - - // cleanup tasks belong to the subscriber as a whole and are only stored on its main subscription, - // so the lazily loaded tasks of a partition are not touched at all - $cleanupTasks = $subscription->isPartition() ? null : $this->cleanupTasks($subscriber); - - if (!$subscription->isPartition() && $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); + // a worker that is processing a subscription is waited for, it would overwrite the changes otherwise + $this->subscriptionManager->forEachClaimedGroup( + new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + ), + new ClaimGroups(partitions: self::PARTITIONS_PER_CLAIM), + function (array $subscriptions): void { + foreach ($subscriptions as $subscription) { + $this->refresh($subscription); + } + }, + wait: true, + // only the subscriptions that change are claimed, checked again once they are claimed + filter: fn (Subscription $subscription): bool => $this->needsRefresh($subscription), + ); + + return new Result(); + } + + private function needsRefresh(Subscription $subscription): bool + { + $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); + + if (!$subscriber) { + return false; } - $this->subscriptionManager->flush(); + return $subscription->runMode() !== $subscriber->metadata()->runMode + || $subscription->group() !== $subscriber->metadata()->group + || (!$subscription->isPartition() && $subscription->cleanupTasks() !== $this->cleanupTasks($subscriber)); + } - return new Result(); + private function refresh(Subscription $subscription): void + { + $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); + + if (!$subscriber) { + return; + } + + $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(), + ), + ); + } + + // cleanup tasks belong to the subscriber as a whole and are only stored on its main subscription, + // so the lazily loaded tasks of a partition are not touched at all + $cleanupTasks = $subscription->isPartition() ? null : $this->cleanupTasks($subscriber); + + if (!$subscription->isPartition() && $subscription->cleanupTasks() !== $cleanupTasks) { + $changed = true; + $subscription->replaceCleanupTasks($cleanupTasks); + + $this->logger?->info( + sprintf( + 'Subscription Engine: Subscription "%s" cleanup tasks changed.', + $subscription->id(), + ), + ); + } + + if (!$changed) { + return; + } + + $this->subscriptionManager->update($subscription); } /** @return list|null */ diff --git a/src/Subscription/Engine/Handler/RemoveHandler.php b/src/Subscription/Engine/Handler/RemoveHandler.php index 2bb2eb2e6..5232bd666 100644 --- a/src/Subscription/Engine/Handler/RemoveHandler.php +++ b/src/Subscription/Engine/Handler/RemoveHandler.php @@ -114,6 +114,7 @@ function (Subscription $subscription): Result { static fn (Subscription $subscription, Throwable $e): Result => new Result( [new Error($subscription->id(), $e->getMessage(), $e)], ), + wait: true, ); return Result::merge($results); diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php index 934eb2dae..bc5a40c6a 100644 --- a/src/Subscription/Engine/Handler/SetupHandler.php +++ b/src/Subscription/Engine/Handler/SetupHandler.php @@ -100,6 +100,7 @@ function (Subscription $subscription) use ($command, &$latestIndex): Result { static fn (Subscription $subscription, Throwable $e): Result => new Result( [new Error($subscription->id(), $e->getMessage(), $e)], ), + wait: true, ); return Result::merge($results); diff --git a/src/Subscription/Engine/Handler/TeardownHandler.php b/src/Subscription/Engine/Handler/TeardownHandler.php index 28cc26973..6329920ae 100644 --- a/src/Subscription/Engine/Handler/TeardownHandler.php +++ b/src/Subscription/Engine/Handler/TeardownHandler.php @@ -124,6 +124,7 @@ function (Subscription $subscription): Result { static fn (Subscription $subscription, Throwable $e): Result => new Result( [new Error($subscription->id(), $e->getMessage(), $e)], ), + wait: true, ); return Result::merge($results); diff --git a/src/Subscription/Engine/Listener/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php index 1e3ec085f..63218864c 100644 --- a/src/Subscription/Engine/Listener/DetachListener.php +++ b/src/Subscription/Engine/Listener/DetachListener.php @@ -4,6 +4,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine\Listener; +use Patchlevel\EventSourcing\Subscription\Engine\ClaimGroups; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnCommand; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionManager; @@ -18,6 +19,8 @@ /** @internal */ final class DetachListener { + private const PARTITIONS_PER_CLAIM = 100; + public function __construct( private readonly SubscriptionManager $subscriptionManager, private readonly SubscriberAccessorRepository $subscriberRepository, @@ -33,6 +36,9 @@ public function __invoke(OnCommand $event): void return; } + /** @var list $detached */ + $detached = []; + $this->subscriptionManager->forEachClaimed( new SubscriptionCriteria( ids: $command->ids, @@ -41,7 +47,7 @@ public function __invoke(OnCommand $event): void // partitions follow their main subscription, checking each of them would cost a claim per partition partitions: false, ), - function (Subscription $subscription): void { + function (Subscription $subscription) use (&$detached): void { $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); if ($subscriber) { @@ -50,26 +56,7 @@ function (Subscription $subscription): void { $subscription->detached(); $this->subscriptionManager->update($subscription); - - // every partition, also booting or failed ones, otherwise they would be retried without a subscriber - $partitions = $this->subscriptionManager->find(new SubscriptionCriteria( - ids: [$subscription->id()], - status: [ - Status::New, - Status::Booting, - Status::Active, - Status::Paused, - Status::Finished, - Status::Error, - Status::Failed, - ], - partitions: true, - )); - - foreach ($partitions as $partition) { - $partition->detached(); - $this->subscriptionManager->update($partition); - } + $detached[] = $subscription->id(); $this->logger?->info( sprintf( @@ -79,5 +66,35 @@ function (Subscription $subscription): void { ); }, ); + + if ($detached === []) { + return; + } + + // every partition, also booting or failed ones, otherwise they would be retried without a subscriber. + // a worker that is processing one is waited for, it would overwrite the status otherwise. + $this->subscriptionManager->forEachClaimedGroup( + new SubscriptionCriteria( + ids: $detached, + status: [ + Status::New, + Status::Booting, + Status::Active, + Status::Paused, + Status::Finished, + Status::Error, + Status::Failed, + ], + partitions: true, + ), + new ClaimGroups(partitions: self::PARTITIONS_PER_CLAIM), + function (array $partitions): void { + foreach ($partitions as $partition) { + $partition->detached(); + $this->subscriptionManager->update($partition); + } + }, + wait: true, + ); } } diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php index 6d3e09f6c..42c43eb46 100644 --- a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -235,6 +235,7 @@ function (Subscription $main) use ($throwable): bool { return true; }, + wait: true, ); if ($stopped === [] && !$this->isStopped($subscriberId)) { @@ -264,6 +265,7 @@ function (Subscription $partition): void { $this->subscriptionManager->update($partition); }, + wait: true, ); return true; @@ -297,6 +299,7 @@ function (Subscription $partition) use ($failures): void { $partition->doRetry(); $this->failedMessageProcessor->process($partition, $throwable, $message, $index); }, + wait: true, ); } diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 85ef2bbdb..515f1da33 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -53,6 +53,8 @@ public function __construct( * @param Closure(Subscription):T $closure * @param Closure(Subscription, Throwable):T $onError * @param Closure(Subscription):bool $filter decides on the unclaimed snapshot whether to claim at all + * @param bool $wait waits for subscriptions held by another worker instead of + * leaving them out, for commands that have to reach every one * * @return list * @@ -63,6 +65,7 @@ public function forEachClaimed( Closure $closure, Closure|null $onError = null, Closure|null $filter = null, + bool $wait = false, ): array { $snapshot = $this->subscriptionStore->find($criteria); @@ -87,17 +90,19 @@ static function (array $subscriptions) use ($closure): mixed { return $closure($subscriptions[0]); }, $groupOnError, + $wait, ); } /** * Claims subscriptions in groups in one transaction and passes them to the closure together. Partitions and other * subscriptions are never mixed, because the others have to read the whole stream while partitions only read their - * inbox. A subscription that is locked by another worker is left out of its group. + * inbox. A subscription that is locked by another worker is left out of its group, unless $wait is set. * If the closure throws, every subscription of the group goes into the error status. * * @param Closure(non-empty-list):T $closure * @param Closure(non-empty-list, Throwable):T $onError + * @param Closure(Subscription):bool $filter decides on the unclaimed snapshot whether to claim at all * * @return list * @@ -108,12 +113,21 @@ public function forEachClaimedGroup( ClaimGroups $groups, Closure $closure, Closure|null $onError = null, + bool $wait = false, + Closure|null $filter = null, ): array { + $snapshot = $this->subscriptionStore->find($criteria); + + if ($filter !== null) { + $snapshot = array_values(array_filter($snapshot, $filter)); + } + return $this->claimEach( - $this->groups($this->subscriptionStore->find($criteria), $groups), + $this->groups($snapshot, $groups), $criteria, $closure, $onError, + $wait, ); } @@ -126,8 +140,13 @@ public function forEachClaimedGroup( * * @template T */ - private function claimEach(array $groups, SubscriptionCriteria $criteria, Closure $closure, Closure|null $onError): array - { + private function claimEach( + array $groups, + SubscriptionCriteria $criteria, + Closure $closure, + Closure|null $onError, + bool $wait, + ): array { $results = []; foreach ($groups as $candidates) { @@ -136,8 +155,8 @@ private function claimEach(array $groups, SubscriptionCriteria $criteria, Closur try { $outcome = $this->subscriptionStore->inLock( /** @return T|null */ - function () use ($candidates, $criteria, $closure, $onError, &$skipped): mixed { - $subscriptions = $this->claim($candidates, $criteria); + function () use ($candidates, $criteria, $closure, $onError, $wait, &$skipped): mixed { + $subscriptions = $this->claim($candidates, $criteria, $wait); if ($subscriptions === []) { $skipped = true; @@ -202,11 +221,12 @@ function () use ($candidates, $criteria, $closure, $onError, &$skipped): mixed { * * @return list */ - private function claim(array $candidates, SubscriptionCriteria $criteria): array + private function claim(array $candidates, SubscriptionCriteria $criteria, bool $wait): array { return $this->subscriptionStore->claim( array_map(static fn (Subscription $subscription): string => $subscription->id(), $candidates), $criteria, + $wait, ); } diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 55ae96c8f..b5c9180f5 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -277,7 +277,7 @@ public function stats(SubscriptionCriteria $criteria): SubscriptionStats * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array { $qb = $this->connection->createQueryBuilder() ->select('*') @@ -290,7 +290,7 @@ public function claim(array $ids, SubscriptionCriteria $criteria): array $this->applyPending($qb, $criteria); if (!$this->connection->getDatabasePlatform() instanceof SQLitePlatform) { - $qb->forUpdate(ConflictResolutionMode::SKIP_LOCKED); + $qb->forUpdate($wait ? ConflictResolutionMode::ORDINARY : ConflictResolutionMode::SKIP_LOCKED); } /** @var list $result */ diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index 009ecd754..1e173b09b 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -78,7 +78,7 @@ public function stats(SubscriptionCriteria $criteria): SubscriptionStats * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array { $claimed = []; diff --git a/src/Subscription/Store/SubscriptionStore.php b/src/Subscription/Store/SubscriptionStore.php index e999e0254..528621862 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -23,14 +23,15 @@ public function find(SubscriptionCriteria|null $criteria = null): array; public function stats(SubscriptionCriteria $criteria): SubscriptionStats; /** - * Claims the subscriptions via row locks (SKIP LOCKED), leaving out the ones that are held by another worker or no - * longer match the criteria. + * Claims the subscriptions via row locks, leaving out the ones that no longer match the criteria. + * By default the ones held by another worker are left out as well (SKIP LOCKED), that is what processing needs. + * With $wait the claim waits until the other worker released them, that is what a command like pause needs. * * @param non-empty-list $ids * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array; + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array; /** @throws SubscriptionAlreadyExists */ public function add(Subscription ...$subscriptions): void; diff --git a/tests/Integration/Subscription/PlainSubscriptionStore.php b/tests/Integration/Subscription/PlainSubscriptionStore.php index 4f45fba79..9394a97f6 100644 --- a/tests/Integration/Subscription/PlainSubscriptionStore.php +++ b/tests/Integration/Subscription/PlainSubscriptionStore.php @@ -39,9 +39,9 @@ public function find(SubscriptionCriteria|null $criteria = null): array * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array { - return $this->store->claim($ids, $criteria); + return $this->store->claim($ids, $criteria, $wait); } public function add(Subscription ...$subscriptions): void diff --git a/tests/Integration/Subscription/WaitingClaimTest.php b/tests/Integration/Subscription/WaitingClaimTest.php new file mode 100644 index 000000000..ea6c45df0 --- /dev/null +++ b/tests/Integration/Subscription/WaitingClaimTest.php @@ -0,0 +1,153 @@ + */ + private array $processes = []; + + public function setUp(): void + { + $this->connection = DbalManager::createConnection(); + + if ($this->connection->getDatabasePlatform() instanceof SQLitePlatform) { + self::markTestSkipped('needs a database that several processes can share'); + } + + $this->store = new DoctrineSubscriptionStore($this->connection); + (new DoctrineSchemaDirector($this->connection, $this->store))->create(); + } + + public function tearDown(): void + { + foreach ($this->processes as $process) { + proc_close($process); + } + + $this->connection->close(); + } + + public function testClaimWaitsForALockedSubscription(): void + { + $this->store->add(new Subscription('profile', status: Status::Active, position: 0)); + + $this->lock('profile', 1.0); + + $criteria = new SubscriptionCriteria(status: [Status::Active]); + + $this->connection->beginTransaction(); + self::assertSame([], $this->store->claim(['profile'], $criteria), 'processing leaves it out'); + $this->connection->commit(); + + $start = microtime(true); + $this->connection->beginTransaction(); + $claimed = $this->store->claim(['profile'], $criteria, wait: true); + $this->connection->commit(); + + self::assertSame(['profile'], array_map(static fn (Subscription $subscription): string => $subscription->id(), $claimed)); + self::assertGreaterThan(0.3, microtime(true) - $start, 'waited for the lock'); + } + + public function testPauseReachesALockedPartition(): void + { + $main = new Subscription('profile', status: Status::Active, position: 10); + $partition = Subscription::partition($main, 'a', 5); + $this->store->add($main, $partition, Subscription::partition($main, 'b', 6)); + + $this->lock($partition->id(), 1.0); + + $engine = new DefaultSubscriptionEngine( + new StoreMessageLoader(new InMemoryStore()), + $this->store, + new MetadataSubscriberAccessorRepository([]), + ); + + $engine->execute(new Pause(['profile'])); + + foreach ($this->store->find(new SubscriptionCriteria(ids: ['profile'])) as $subscription) { + self::assertSame(Status::Paused, $subscription->status(), $subscription->id()); + } + } + + /** Holds the row lock in another process, like a worker that is processing the subscription. */ + private function lock(string $id, float $seconds): void + { + $readyFile = tempnam(sys_get_temp_dir(), 'subscription-lock'); + + if ($readyFile === false) { + throw new RuntimeException('no temp file'); + } + + unlink($readyFile); + + $process = proc_open( + [PHP_BINARY, __DIR__ . '/../Worker/lock-subscription.php', $id, (string)$seconds, $readyFile], + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes, + null, + getenv(), + ); + + if (!is_resource($process)) { + throw new RuntimeException('lock process could not be started'); + } + + foreach ($pipes as $pipe) { + fclose($pipe); + } + + $this->processes[] = $process; + + $deadline = microtime(true) + 10; + + while (!file_exists($readyFile)) { + if (microtime(true) > $deadline) { + self::fail('the lock process did not start'); + } + + usleep(10_000); + } + + unlink($readyFile); + } +} diff --git a/tests/Integration/Worker/lock-subscription.php b/tests/Integration/Worker/lock-subscription.php new file mode 100644 index 000000000..a42c3dc03 --- /dev/null +++ b/tests/Integration/Worker/lock-subscription.php @@ -0,0 +1,31 @@ +parse($dbUrl)); +$connection->beginTransaction(); +$connection->fetchAllAssociative('SELECT id FROM subscriptions WHERE id = ? FOR UPDATE', [$id]); + +touch($readyFile); +usleep((int)($seconds * 1_000_000)); + +$connection->commit(); diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 841a1a411..01c24f060 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -62,13 +62,13 @@ public function find(SubscriptionCriteria|null $criteria = null): array * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array { foreach ($ids as $id) { $this->claimedIds[] = $id; } - return $this->parentStore->claim($ids, $criteria); + return $this->parentStore->claim($ids, $criteria, $wait); } /** diff --git a/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php index f3b8ac6b7..78087acc5 100644 --- a/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php @@ -71,9 +71,9 @@ public function stats(SubscriptionCriteria $criteria): SubscriptionStats * * @return list */ - public function claim(array $ids, SubscriptionCriteria $criteria): array + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array { - return $this->inner->claim($ids, $criteria); + return $this->inner->claim($ids, $criteria, $wait); } public function add(Subscription ...$subscriptions): void From aa7f57bd5c35961e92f2af25bf5754079b8e050e Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:10:55 +0200 Subject: [PATCH 46/57] Never start a partition in the error status of its main subscription Since the router creates the partitions at the end of the run, the main subscription can already be in error by then. The partitions copied its status without an error, so nothing ever processed or retried them and their inbox stayed unprocessed after the main subscription recovered. A new partition now starts with the status the main subscription had before its error. --- src/Subscription/Subscription.php | 11 +++- .../PartitionedSubscriptionTest.php | 61 +++++++++++++++++++ tests/Unit/Subscription/SubscriptionTest.php | 25 ++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/src/Subscription/Subscription.php b/src/Subscription/Subscription.php index 07a7ec5d0..239f2bd51 100644 --- a/src/Subscription/Subscription.php +++ b/src/Subscription/Subscription.php @@ -38,7 +38,8 @@ public function __construct( /** * Creates the partition of a partitioned subscriber. The partition belongs to the same subscriber as the given main - * subscription and starts with its group, run mode and status. + * subscription and starts with its group, run mode and status. A failure of the main subscription is not the one of + * the partition: if the main subscription is in error or failed, the partition starts with the status before. */ public static function partition(self $main, string $partitionKey, int $position): self { @@ -46,11 +47,17 @@ public static function partition(self $main, string $partitionKey, int $position throw new PartitionOfPartitionNotPossible($main->id()); } + $status = $main->status(); + + if ($status === Status::Error || $status === Status::Failed) { + $status = $main->subscriptionError()->previousStatus ?? Status::Active; + } + return new self( self::partitionId($main->id(), $partitionKey), $main->group(), $main->runMode(), - $main->status(), + $status, $position, partitionKey: $partitionKey, ); diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index d16c77ecb..d131aa29a 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -419,6 +419,67 @@ public function testPartitionsOfMessagesRoutedBeforeAnErrorAreCreated(): void ], array_keys($this->partitions()), ); + + foreach ($this->partitions() as $partition) { + self::assertSame(Status::Active, $partition->status(), 'the error of the main subscription is not theirs'); + } + + // the bug is fixed, the main subscription is retried and the partitions process what was routed before + ConfigurablePartitioner::$key = null; + $this->clock->sleep(10); + $this->runUntilFinished($engine); + + self::assertSame(['name-0'], $projection->processed[$firstId]); + self::assertSame(['name-1'], $projection->processed[$secondId]); + self::assertSame(['name-2'], $projection->processed[$brokenId]); + } + + /** The same while booting: the partitions start booting and finish once the main subscription booted. */ + public function testPartitionsOfMessagesRoutedBeforeAnErrorWhileBooting(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + [$firstId, $brokenId] = $this->createProfiles(2); + + $engine->execute(new Setup()); + + ConfigurablePartitioner::$key = static function (Message $message) use ($brokenId): string { + $streamName = $message->header(StreamNameHeader::class)->streamName; + + if ($streamName === 'profile-' . $brokenId) { + throw new RuntimeException('no key'); + } + + return $streamName; + }; + + $engine->execute(new Boot()); + + self::assertSame(Status::Error, $this->subscriptionStore->get(self::MAIN)->status()); + self::assertSame( + Status::Booting, + $this->subscriptionStore->get(Subscription::partitionId(self::MAIN, 'profile-' . $firstId))->status(), + ); + + ConfigurablePartitioner::$key = null; + $this->clock->sleep(10); + + for ($i = 0; $i < 20; $i++) { + $result = $engine->execute(new Boot()); + self::assertInstanceOf(ProcessedResult::class, $result); + + if ($result->finished) { + break; + } + } + + self::assertSame(['name-0'], $projection->processed[$firstId]); + self::assertSame(['name-1'], $projection->processed[$brokenId]); + + foreach ($this->partitions() as $partition) { + self::assertSame(Status::Active, $partition->status(), $partition->id()); + } } /** The repository that runs the engine after every save, e.g. to read your own writes. */ diff --git a/tests/Unit/Subscription/SubscriptionTest.php b/tests/Unit/Subscription/SubscriptionTest.php index cf0530632..c32519e83 100644 --- a/tests/Unit/Subscription/SubscriptionTest.php +++ b/tests/Unit/Subscription/SubscriptionTest.php @@ -14,6 +14,7 @@ use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Patchlevel\EventSourcing\Subscription\ThrowableToErrorContextTransformer; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; use stdClass; @@ -287,6 +288,30 @@ public function testPartition(): void self::assertNull($main->partitionKey()); } + /** @return iterable main status, status before the error, partition status */ + public static function partitionStatus(): iterable + { + yield 'active' => [Status::Active, null, Status::Active]; + yield 'booting' => [Status::Booting, null, Status::Booting]; + yield 'error while active' => [Status::Error, Status::Active, Status::Active]; + yield 'error while booting' => [Status::Error, Status::Booting, Status::Booting]; + yield 'failed while active' => [Status::Failed, Status::Active, Status::Active]; + } + + /** The failure of the main subscription is not the one of a partition it creates. */ + #[DataProvider('partitionStatus')] + public function testPartitionStatus(Status $mainStatus, Status|null $previousStatus, Status $expected): void + { + $main = new Subscription( + 'profile', + status: $mainStatus, + position: 10, + error: $previousStatus !== null ? new SubscriptionError('routing failed', $previousStatus) : null, + ); + + self::assertSame($expected, Subscription::partition($main, 'tenant-a', 4)->status()); + } + public function testPartitionKeyMayContainTheSeparator(): void { $partition = Subscription::partition(new Subscription('profile'), 'a#b', 0); From a355edff04e6fe3a7a2bf857554c3d7b66cf2ce9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:16:23 +0200 Subject: [PATCH 47/57] Keep failures of one subscription out of its group A listener of OnHandleMessageSuccess, like a batch flush, ran outside the error handling of the message processor. Its exception failed the whole claimed group: every partition of the group went into error, members that had already failed lost their error and were never retried, and the circuit breaker counted every member. The exception is now an error of that one subscription, and a failing group no longer overwrites the error of a member. --- src/Subscription/Engine/MessageProcessor.php | 39 +++++++++++++------ .../Engine/SubscriptionManager.php | 5 +++ .../PartitionedSubscriptionTest.php | 38 ++++++++++++++++++ .../PartitionedBatchProfileProjection.php | 12 ++++++ .../Engine/SubscriptionManagerTest.php | 26 +++++++++++++ 5 files changed, 109 insertions(+), 11 deletions(-) diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index e3b2825d7..15b840a36 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -86,9 +86,7 @@ public function process( ), ); - $this->success($index, $message, $subscription); - - return null; + return $this->success($index, $message, $subscription, $subscriber); } if (!$subscription->isPartition() && $subscriber->metadata()->partition !== null) { @@ -102,9 +100,7 @@ public function process( return $this->error($index, $message, $subscription, $subscriber, $e); } - $this->success($index, $message, $subscription); - - return null; + return $this->success($index, $message, $subscription, $subscriber); } try { @@ -142,7 +138,11 @@ public function process( return $this->error($index, $message, $subscription, $subscriber, $e); } - $this->success($index, $message, $subscription); + $error = $this->success($index, $message, $subscription, $subscriber); + + if ($error !== null) { + return $error; + } $this->logger?->debug( sprintf( @@ -156,16 +156,33 @@ public function process( return null; } - private function success(int $index, Message $message, Subscription $subscription): void - { + /** + * A listener can fail as well, e.g. a batch that is flushed after this message. That is a failure of this + * subscription, not of the others processed together with it. + * + * @param MetadataSubscriberAccessor $subscriber + */ + private function success( + int $index, + Message $message, + Subscription $subscription, + MetadataSubscriberAccessor $subscriber, + ): Error|null { $event = new OnHandleMessageSuccess($subscription, $message, $index); - $this->eventDispatcher->dispatch($event); + + try { + $this->eventDispatcher->dispatch($event); + } catch (Throwable $e) { + return $this->error($index, $message, $subscription, $subscriber, $e); + } if (!$event->shouldChangePosition) { - return; + return null; } $subscription->changePosition($index); + + return null; } /** @param MetadataSubscriberAccessor $subscriber */ diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 515f1da33..9fcaaf783 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -170,6 +170,11 @@ function () use ($candidates, $criteria, $closure, $onError, $wait, &$skipped): throw $e; } catch (Throwable $e) { foreach ($subscriptions as $subscription) { + // its own error is kept, otherwise it would never be retried + if ($subscription->isError() || $subscription->isFailed()) { + continue; + } + $this->logger?->error(sprintf( 'Subscription Engine: Subscription "%s" failed: %s', $subscription->id(), diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index d131aa29a..1a181e97c 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -636,6 +636,44 @@ public function testBucketPartitioner(): void } } + /** A failing flush only stops its own partition, not the others claimed in the same group. */ + public function testFailingBatchFlushOnlyStopsItsPartition(): void + { + $projection = new PartitionedBatchProfileProjection(); + $projection->failingNames = ['broken']; + $engine = $this->engine($projection); + $subscriberId = PartitionedBatchProfileProjection::SUBSCRIBER_ID; + + $engine->execute(new Setup(skipBooting: true)); + + $ids = $this->createProfiles(7, ['name-0', 'name-1', 'name-2', 'broken', 'name-4', 'name-5', 'name-6']); + + // the second message of every partition flushes its batch + foreach ($ids as $id) { + $this->changeName(ProfileId::fromString($id), 'changed-' . $id); + } + + $this->runUntilFinished($engine); + + self::assertSame(Status::Active, $this->subscriptionStore->get($subscriberId)->status(), 'no systemic failure'); + + foreach ($ids as $i => $id) { + $partition = $this->subscriptionStore->get(Subscription::partitionId($subscriberId, 'profile-' . $id)); + + if ($i === 3) { + self::assertSame(Status::Error, $partition->status()); + self::assertSame('Forced flush failure for broken', $partition->subscriptionError()?->errorMessage); + self::assertSame(Status::Active, $partition->subscriptionError()->previousStatus, 'it is retried'); + self::assertArrayNotHasKey($id, $projection->flushed); + + continue; + } + + self::assertSame(Status::Active, $partition->status(), $partition->id()); + self::assertSame(['name-' . $i, 'changed-' . $id], $projection->flushed[$id]); + } + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine { diff --git a/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php index f841c41ad..bb0cfd879 100644 --- a/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php +++ b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php @@ -14,6 +14,9 @@ use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; +use RuntimeException; + +use function in_array; /** Collects the names of a batch and only stores them on flush. */ #[Projector(self::SUBSCRIBER_ID)] @@ -27,6 +30,9 @@ final class PartitionedBatchProfileProjection public int $flushCount = 0; + /** @var list names that let the flush fail */ + public array $failingNames = []; + /** @param ArrayObject $state */ #[Subscribe(ProfileCreated::class)] public function handleProfileCreated(ProfileCreated $event, #[BatchState] @@ -56,6 +62,12 @@ public function flush(ArrayObject $state): void { $this->flushCount++; + foreach ($state as [, $name]) { + if (in_array($name, $this->failingNames, true)) { + throw new RuntimeException('Forced flush failure for ' . $name); + } + } + foreach ($state as [$profileId, $name]) { $this->flushed[$profileId][] = $name; } diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index a7a08bda2..b47fc5261 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -232,6 +232,32 @@ public function testForEachClaimedGroupPutsTheWholeGroupIntoError(): void self::assertSame(Status::Error, $store->get('b')->status()); } + public function testForEachClaimedGroupKeepsTheErrorOfAMemberThatAlreadyFailed(): void + { + $store = new InMemorySubscriptionStore([ + new Subscription('a', status: Status::Active), + new Subscription('b', status: Status::Active), + ]); + + $manager = new SubscriptionManager($store); + + $manager->forEachClaimedGroup( + new SubscriptionCriteria(), + new ClaimGroups(subscriptions: 2), + static function (array $subscriptions): never { + // the first one failed on its own before the whole group failed + $subscriptions[0]->error('handler failed'); + + throw new RuntimeException('boom'); + }, + ); + + $a = $store->get('a'); + self::assertSame('handler failed', $a->subscriptionError()?->errorMessage); + self::assertSame(Status::Active, $a->subscriptionError()->previousStatus, 'it is still retried'); + self::assertSame('boom', $store->get('b')->subscriptionError()?->errorMessage); + } + public function testForEachClaimedGroupOnlyGroupsSubscriptionsWithClosePositions(): void { $store = new InMemorySubscriptionStore([ From 3ba93a12acf559240e2ce64d004362f40b849860 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:23:57 +0200 Subject: [PATCH 48/57] Store nothing about a held back failure before the run ends A partition without retries left was put into error while its failure was held back, and only failed at the end of the run. If the run did not get there, the partition stayed in error forever without retries left. Now it stays active, only the position of the messages handled before is stored. If the run does not get to its end, the message is processed again in the next run. A message another worker processed in the meantime is not failed afterwards. --- .../PartitionCircuitBreakerSubscriber.php | 14 +++--- .../PartitionCircuitBreakerSubscriberTest.php | 45 ++++++++++++++++++- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php index 42c43eb46..49c760e5d 100644 --- a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -109,7 +109,7 @@ public function onHandleMessageError(OnHandleMessageError $event): void /** * A partition without retries left would go into the failed status and call its #[OnFailed] method, which may * skip the message. During a systemic failure that must not happen, so the transition is held back until the end - * of the command: the partition goes into the error status for now and only fails if the breaker did not trip. + * of the command: the partition stays active and only fails if the breaker did not trip. */ public function holdBackFailure(OnHandleMessageError $event): void { @@ -119,8 +119,9 @@ public function holdBackFailure(OnHandleMessageError $event): void $partition = $event->subscription; + // nothing about the failure is stored yet, only the position of the messages handled before. + // if the run does not get to its end, the message is simply processed again in the next run. $event->transitionToFailed = false; - $partition->error($event->throwable); $this->subscriptionManager->update($partition); $this->heldBackFailures[$partition->subscriberId()][$partition->id()] = [ @@ -287,7 +288,7 @@ private function isStopped(string $subscriberId): bool private function fail(array $failures): void { $this->subscriptionManager->forEachClaimed( - new SubscriptionCriteria(ids: array_keys($failures), status: [Status::Error], partitions: true), + new SubscriptionCriteria(ids: array_keys($failures), status: [Status::Active, Status::Booting], partitions: true), function (Subscription $partition) use ($failures): void { if (!isset($failures[$partition->id()])) { return; @@ -295,8 +296,11 @@ function (Subscription $partition) use ($failures): void { [$throwable, $message, $index] = $failures[$partition->id()]; - // back to the status before the error, the failed message processor decides about the rest - $partition->doRetry(); + // another worker may have processed the message since, then it already failed there + if (($partition->position() ?? -1) >= $index) { + return; + } + $this->failedMessageProcessor->process($partition, $throwable, $message, $index); }, wait: true, diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php index a3b6e3f86..c3b9758b0 100644 --- a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -181,7 +181,7 @@ public function testHeldBackFailureIsDroppedIfTheBreakerTrips(): void $breaker->holdBackFailure($event); self::assertFalse($event->transitionToFailed, 'the fail subscriber does not see it'); - self::assertSame(Status::Error, $this->partitions[$i]->status()); + self::assertSame(Status::Active, $this->partitions[$i]->status(), 'nothing about the failure is stored yet'); } $breaker->onResult(new OnResult(new Run(), new Result())); @@ -209,6 +209,49 @@ public function testHeldBackFailureFailsThePartitionWithoutTrip(): void self::assertSame(Status::Failed, $this->store->get($this->partitions[0]->id())->status()); } + /** If the run does not get to its end, the partition is still active and processes the message again. */ + public function testHeldBackFailureSurvivesACrash(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $event = $this->error($this->partitions[0]); + $event->transitionToFailed = true; + + $breaker->onHandleMessageError($event); + $breaker->holdBackFailure($event); + $this->store->update($this->partitions[0]); + + // the process dies before the result, the next command starts fresh + $breaker->onCommand(new OnCommand(new Run())); + $breaker->onResult(new OnResult(new Run(), new Result())); + + $partition = $this->store->get($this->partitions[0]->id()); + self::assertSame(Status::Active, $partition->status()); + self::assertSame(0, $partition->position(), 'the message is processed again'); + } + + public function testHeldBackFailureOfAMessageProcessedMeanwhile(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $event = $this->error($this->partitions[0]); + $event->transitionToFailed = true; + + $breaker->onHandleMessageError($event); + $breaker->holdBackFailure($event); + + // another worker processed the message in the meantime + $partition = $this->store->get($this->partitions[0]->id()); + $partition->changePosition(1); + $this->store->update($partition); + + $breaker->onResult(new OnResult(new Run(), new Result())); + + self::assertSame(Status::Active, $this->store->get($this->partitions[0]->id())->status()); + } + public function testOnlyRunAndBoot(): void { $breaker = $this->breaker(1); From b0df88b789c5a0f8a12033da94c13b36e89026d9 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:26:57 +0200 Subject: [PATCH 49/57] Drop held back failures whenever the circuit breaker trips If the breaker tripped but the main subscription was not running, e.g. paused, it could not be stopped and the held back failures still ran #[OnFailed], which could skip the messages during a systemic failure. They are now dropped whenever the breaker trips, the partitions stay active and try again in the next run. --- .../PartitionCircuitBreakerSubscriber.php | 8 ++--- .../PartitionCircuitBreakerSubscriberTest.php | 31 +++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php index 49c760e5d..71581b83b 100644 --- a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -159,10 +159,10 @@ public function onResult(OnResult $event): void } foreach ($this->tripped as $subscriberId => $throwable) { - if ($this->stopSubscriber($subscriberId, $throwable)) { - // the partitions did not fail on their own, they are set back - unset($this->heldBackFailures[$subscriberId]); + // the partitions did not fail on their own, they stay active and try again in the next run + unset($this->heldBackFailures[$subscriberId]); + if ($this->stopSubscriber($subscriberId, $throwable)) { continue; } @@ -241,7 +241,7 @@ function (Subscription $main) use ($throwable): bool { if ($stopped === [] && !$this->isStopped($subscriberId)) { $this->logger?->warning(sprintf( - 'Subscription Engine: The main subscription of subscriber "%s" could not be stopped, it is held by another worker.', + 'Subscription Engine: The main subscription of subscriber "%s" could not be stopped, it is not running.', $subscriberId, )); diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php index c3b9758b0..1d92cbf38 100644 --- a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -252,6 +252,37 @@ public function testHeldBackFailureOfAMessageProcessedMeanwhile(): void self::assertSame(Status::Active, $this->store->get($this->partitions[0]->id())->status()); } + /** A main subscription that is not running can not be stopped, the held back failures are dropped anyway. */ + public function testHeldBackFailureIsDroppedIfTheMainSubscriptionCanNotBeStopped(): void + { + $breaker = $this->breaker(3); + $breaker->onCommand(new OnCommand(new Run())); + + $main = $this->store->get('profile'); + $main->pause(); + $this->store->update($main); + + foreach ([0, 1, 2] as $i) { + $event = $this->error($this->partitions[$i]); + $event->transitionToFailed = true; + + $breaker->onHandleMessageError($event); + $breaker->holdBackFailure($event); + $this->store->update($this->partitions[$i]); + } + + $breaker->onResult(new OnResult(new Run(), new Result())); + + self::assertSame(Status::Paused, $this->store->get('profile')->status()); + + foreach ([0, 1, 2] as $i) { + $partition = $this->store->get($this->partitions[$i]->id()); + + self::assertSame(Status::Active, $partition->status(), 'not failed during a systemic failure'); + self::assertSame(0, $partition->position(), 'the message is not skipped'); + } + } + public function testOnlyRunAndBoot(): void { $breaker = $this->breaker(1); From ba086e3b6e71c698ff8622ac16164e1bf17f6ea3 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:32:49 +0200 Subject: [PATCH 50/57] Decide a stop before OnHandleMessage A listener stopped a subscription through OnHandleMessage::$stop, then neither OnHandleMessageSuccess nor OnHandleMessageError followed. Paired listeners, like one that pushes a message context or opens a span, were left open. The stop is now decided in the new OnBeforeHandleMessage event, OnHandleMessage is only dispatched if the message is processed, so it is always followed by a success or an error. --- docs/subscription.md | 5 ++++ .../Engine/Event/OnBeforeHandleMessage.php | 28 +++++++++++++++++ .../Engine/Event/OnHandleMessage.php | 6 ---- .../Engine/Listener/BatchSubscriber.php | 5 ---- .../PartitionCircuitBreakerSubscriber.php | 7 ++--- src/Subscription/Engine/MessageProcessor.php | 14 ++++----- .../PartitionedSubscriptionTest.php | 30 +++++++++++++++++-- .../PartitionCircuitBreakerSubscriberTest.php | 8 ++--- .../Engine/MessageProcessorTest.php | 8 ++++- .../Engine/SubscriptionRunnerTest.php | 4 +-- 10 files changed, 83 insertions(+), 32 deletions(-) create mode 100644 src/Subscription/Engine/Event/OnBeforeHandleMessage.php diff --git a/docs/subscription.md b/docs/subscription.md index f74f747e0..911cf1f57 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1572,12 +1572,17 @@ You can register your own listeners to hook into the engine, for example for log |--------------------------|----------------------------------------------------------------------| | `OnCommand` | A command was passed to the engine for execution | | `OnSubscriptions` | The engine determined the subscriptions for the current command | +| `OnBeforeHandleMessage` | Before `OnHandleMessage`, a listener can stop the subscription here | | `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 | | `OnSubscriptionProcessed`| A subscription finished its stream (ended or limit reached) | | `OnResult` | The engine finished the command and returns the result | +Every `OnHandleMessage` is followed by exactly one `OnHandleMessageSuccess` or `OnHandleMessageError`. +With `OnBeforeHandleMessage::$stop` a listener stops the subscription for the rest of the run, +the message and the following ones are processed in a later run and `OnHandleMessage` is not dispatched. + ```php use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; use Symfony\Component\EventDispatcher\EventDispatcher; diff --git a/src/Subscription/Engine/Event/OnBeforeHandleMessage.php b/src/Subscription/Engine/Event/OnBeforeHandleMessage.php new file mode 100644 index 000000000..e3ed84692 --- /dev/null +++ b/src/Subscription/Engine/Event/OnBeforeHandleMessage.php @@ -0,0 +1,28 @@ +stop) { - return; - } - $subscriberId = $event->subscription->id(); if ($this->batchManager->has($subscriberId)) { diff --git a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php index 71581b83b..702d046dd 100644 --- a/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -7,8 +7,8 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnBeforeHandleMessage; 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\Event\OnResult; @@ -76,7 +76,7 @@ public function onCommand(OnCommand $event): void $this->heldBackFailures = []; } - public function onHandleMessage(OnHandleMessage $event): void + public function onBeforeHandleMessage(OnBeforeHandleMessage $event): void { if (!$event->subscription->isPartition() || !isset($this->tripped[$event->subscription->subscriberId()])) { return; @@ -349,8 +349,7 @@ public static function getSubscribedEvents(): array { return [ OnCommand::class => 'onCommand', - // before the batch subscriber, a skipped message must not open a batch - OnHandleMessage::class => ['onHandleMessage', 32], + OnBeforeHandleMessage::class => 'onBeforeHandleMessage', OnHandleMessageSuccess::class => 'onHandleMessageSuccess', OnHandleMessageError::class => [ ['onHandleMessageError', 0], diff --git a/src/Subscription/Engine/MessageProcessor.php b/src/Subscription/Engine/MessageProcessor.php index 15b840a36..c7fdf5e5f 100644 --- a/src/Subscription/Engine/MessageProcessor.php +++ b/src/Subscription/Engine/MessageProcessor.php @@ -7,6 +7,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\ArgumentMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnBeforeHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageSuccess; @@ -104,14 +105,11 @@ public function process( } try { - $event = new OnHandleMessage( - $subscription, - $message, - ); + // decided before OnHandleMessage, which is always followed by a success or an error + $before = new OnBeforeHandleMessage($subscription, $message); + $this->eventDispatcher->dispatch($before); - $this->eventDispatcher->dispatch($event); - - if ($event->stop) { + if ($before->stop) { $this->logger?->debug(sprintf( 'Subscription Engine: Subscription "%s" stopped before the event "%s".', $subscription->id(), @@ -121,6 +119,8 @@ public function process( return new ProcessingStopped(); } + $this->eventDispatcher->dispatch(new OnHandleMessage($subscription, $message)); + $context = new ArgumentResolverContext($message, $subscription, $subscriber->metadata()); foreach ($subscribeMethods as $subscribeMethod) { diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 1a181e97c..e3f1ddf9e 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -24,6 +24,9 @@ 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\Event\OnHandleMessage; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageError; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessageSuccess; use Patchlevel\EventSourcing\Subscription\Engine\EventFilteredStoreMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\ProcessedResult; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; @@ -49,6 +52,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; use RuntimeException; +use Symfony\Component\EventDispatcher\EventDispatcher; use function array_keys; use function array_map; @@ -675,8 +679,12 @@ public function testFailingBatchFlushOnlyStopsItsPartition(): void } /** @param 'index'|'plain'|'unsupported' $messageLoader */ - private function engine(object $subscriber, string $messageLoader = 'index', bool $inboxStore = true): SubscriptionEngine - { + private function engine( + object $subscriber, + string $messageLoader = 'index', + bool $inboxStore = true, + EventDispatcher $eventDispatcher = new EventDispatcher(), + ): SubscriptionEngine { $store = $this->store = new StreamDoctrineDbalStore( $this->connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/Events']), @@ -709,6 +717,7 @@ private function engine(object $subscriber, string $messageLoader = 'index', boo new RetryStrategyRepository([ RetryStrategyRepository::DEFAULT_STRATEGY_NAME => new ClockBasedRetryStrategy($this->clock), ]), + eventDispatcher: $eventDispatcher, ); } @@ -940,8 +949,22 @@ public function testSubscriptionStoreWithoutInbox(): void /** Every handler fails, like when the database of the projection is down. */ public function testCircuitBreakerStopsTheSubscriberOnASystemicFailure(): void { + // paired listeners like a message context rely on a success or an error after every OnHandleMessage, + // only the partitions call the subscriber, the main subscription reports a routed message as success only + $open = 0; + $eventDispatcher = new EventDispatcher(); + $eventDispatcher->addListener(OnHandleMessage::class, static function (OnHandleMessage $event) use (&$open): void { + $open += $event->subscription->isPartition() ? 1 : 0; + }); + $eventDispatcher->addListener(OnHandleMessageSuccess::class, static function (OnHandleMessageSuccess $event) use (&$open): void { + $open -= $event->subscription->isPartition() ? 1 : 0; + }); + $eventDispatcher->addListener(OnHandleMessageError::class, static function (OnHandleMessageError $event) use (&$open): void { + $open -= $event->subscription->isPartition() ? 1 : 0; + }); + $projection = new PartitionedProfileProjection(); - $engine = $this->engine($projection); + $engine = $this->engine($projection, eventDispatcher: $eventDispatcher); $engine->execute(new Setup(skipBooting: true)); @@ -968,6 +991,7 @@ public function testCircuitBreakerStopsTheSubscriberOnASystemicFailure(): void } self::assertSame(5, $projection->handled, 'only partitions up to the threshold were tried'); + self::assertSame(0, $open, 'every OnHandleMessage got its success or error, also for the stopped partitions'); // the database is back $projection->failingNames = []; diff --git a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php index 1d92cbf38..d77131d8c 100644 --- a/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -11,8 +11,8 @@ use Patchlevel\EventSourcing\Subscription\Engine\Command\Run; use Patchlevel\EventSourcing\Subscription\Engine\Command\Setup; use Patchlevel\EventSourcing\Subscription\Engine\Error; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnBeforeHandleMessage; 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\Event\OnResult; @@ -339,10 +339,10 @@ private function error(Subscription $partition): OnHandleMessageError return new OnHandleMessageError($partition, new RuntimeException('database down'), $this->message(), 1); } - private function handle(PartitionCircuitBreakerSubscriber $breaker, Subscription $partition): OnHandleMessage + private function handle(PartitionCircuitBreakerSubscriber $breaker, Subscription $partition): OnBeforeHandleMessage { - $event = new OnHandleMessage($partition, $this->message()); - $breaker->onHandleMessage($event); + $event = new OnBeforeHandleMessage($partition, $this->message()); + $breaker->onBeforeHandleMessage($event); return $event; } diff --git a/tests/Unit/Subscription/Engine/MessageProcessorTest.php b/tests/Unit/Subscription/Engine/MessageProcessorTest.php index e0ce64ebc..41cf43e12 100644 --- a/tests/Unit/Subscription/Engine/MessageProcessorTest.php +++ b/tests/Unit/Subscription/Engine/MessageProcessorTest.php @@ -10,6 +10,7 @@ use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Subscription\Engine\Error; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnBeforeHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\MessageProcessor; use Patchlevel\EventSourcing\Subscription\Engine\ProcessingStopped; @@ -161,10 +162,14 @@ public function onProfileVisited(ProfileVisited $event): void } }; + $handled = 0; $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(OnHandleMessage::class, static function (OnHandleMessage $event): void { + $eventDispatcher->addListener(OnBeforeHandleMessage::class, static function (OnBeforeHandleMessage $event): void { $event->stop = true; }); + $eventDispatcher->addListener(OnHandleMessage::class, static function () use (&$handled): void { + $handled++; + }); $processor = new MessageProcessor( new MetadataSubscriberAccessorRepository([$subscriber]), @@ -179,6 +184,7 @@ public function onProfileVisited(ProfileVisited $event): void self::assertInstanceOf(ProcessingStopped::class, $error); self::assertFalse($subscriber->called); + self::assertSame(0, $handled, 'OnHandleMessage is not dispatched, it would have no success or error'); self::assertSame(0, $subscription->position(), 'a stopped subscription does not move the position'); } diff --git a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php index da714589e..d52849420 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php @@ -10,7 +10,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Message\Stream; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; -use Patchlevel\EventSourcing\Subscription\Engine\Event\OnHandleMessage; +use Patchlevel\EventSourcing\Subscription\Engine\Event\OnBeforeHandleMessage; use Patchlevel\EventSourcing\Subscription\Engine\IndexMessageLoader; use Patchlevel\EventSourcing\Subscription\Engine\Listener\BatchSubscriber; use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader; @@ -275,7 +275,7 @@ private function processWithStopAtTheSecondMessage(object $subscriber, Subscript $handled = 0; $eventDispatcher = new EventDispatcher(); - $eventDispatcher->addListener(OnHandleMessage::class, static function (OnHandleMessage $event) use (&$handled): void { + $eventDispatcher->addListener(OnBeforeHandleMessage::class, static function (OnBeforeHandleMessage $event) use (&$handled): void { $handled++; if ($handled !== 2) { From d68c9556e5e058444cf9a4116bc20481d5cb047d Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:37:52 +0200 Subject: [PATCH 51/57] Find main subscriptions without reading their partitions Looking up main subscriptions by id also matched the subscriber id, so every partition of the subscriber was read, once per partition group, in the circuit breaker and in the run snapshot. Main subscriptions are now found by their primary key, and an index on the partition key finds them without ids. With one million partitions on PostgreSQL the run snapshot of the main subscriptions went from 133ms to 0.05ms. --- docs/UPGRADE-4.0.md | 2 ++ docs/subscription.md | 3 +-- src/Subscription/Store/DoctrineSubscriptionStore.php | 12 +++++++++++- .../Store/DoctrineSubscriptionStoreTest.php | 1 + 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index b7ac0acb9..3321b82e5 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -510,6 +510,7 @@ ALTER TABLE subscriptions ADD partition_key VARCHAR(255) DEFAULT NULL; UPDATE subscriptions SET subscriber_id = id; ALTER TABLE subscriptions ALTER COLUMN subscriber_id SET NOT NULL; CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, last_saved_at); +CREATE INDEX IDX_SUBSCRIPTIONS_PARTITION_KEY ON subscriptions (partition_key, status); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` On MySQL and MariaDB the ids and the partition key are compared binary, so that partition keys that only differ in @@ -522,6 +523,7 @@ UPDATE subscriptions SET subscriber_id = id; ALTER TABLE subscriptions MODIFY subscriber_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; ALTER TABLE subscriptions MODIFY id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, last_saved_at); +CREATE INDEX IDX_SUBSCRIPTIONS_PARTITION_KEY ON subscriptions (partition_key, status); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` Adjust the table names if you configured your own. diff --git a/docs/subscription.md b/docs/subscription.md index 911cf1f57..26d6e4773 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -961,8 +961,7 @@ The subscription with the subscriber id is the main subscription. It never calls Instead, it resolves the partition key of every message exactly once and puts the message into the inbox of that partition. Partitions are created on the fly as new keys appear, get the id `{subscriber id}#{partition key}` and start with the group, run mode and status of the main subscription. -A partition only processes the messages in its inbox and is only claimed by a worker if its inbox has messages, -so idle partitions cost nothing. +A partition only processes the messages in its inbox and is only claimed by a worker if its inbox has messages. The built-in message loaders load exactly these messages. A custom message loader can do the same by implementing `IndexMessageLoader`, otherwise a partition reads the range of its inbox and skips the other messages. diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index b5c9180f5..8e9e83a85 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -318,7 +318,15 @@ private function applyPending(QueryBuilder $qb, SubscriptionCriteria $criteria): private function applyCriteria(QueryBuilder $qb, SubscriptionCriteria $criteria): void { - if ($criteria->ids !== null) { + // the id of a main subscription is its subscriber id, so it is found by its primary key alone + if ($criteria->ids !== null && $criteria->partitions === false) { + $qb->andWhere('id IN (:ids)') + ->setParameter( + 'ids', + $criteria->ids, + ArrayParameterType::STRING, + ); + } elseif ($criteria->ids !== null) { $qb->andWhere('id IN (:ids) OR subscriber_id IN (:subscriber_ids)') ->setParameter( 'ids', @@ -969,6 +977,8 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); + // finds the main subscriptions without reading the partitions + $table->addIndex(['partition_key', 'status']); // also finds the idle partitions of a subscriber $table->addIndex(['subscriber_id', 'last_saved_at']); diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index 2e886b5ad..64b97c85f 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -611,6 +611,7 @@ public function testConfigureSchema(): void $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); + $table->addIndex(['partition_key', 'status']); $table->addIndex(['subscriber_id', 'last_saved_at']); $inbox = $expectedSchema->createTable('subscriptions_inbox'); From fa2aaeb2b59245daad1b956e085bbbdde88df468 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:50:32 +0200 Subject: [PATCH 52/57] Boot partitions completely without a limit A partition reads at most 1000 messages of its inbox at a time, so a boot without a limit left partitions with a larger inbox booting, and run never processes booting partitions. Without a limit the boot now repeats the partitions until their inboxes are processed, like it processes the whole stream for other subscriptions. --- .../Engine/Handler/BootHandler.php | 30 ++++++++-------- .../PartitionedSubscriptionTest.php | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 14 deletions(-) diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 145193914..b34b8f52d 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -62,20 +62,22 @@ public function __invoke(Command $command): ProcessedResult // the partitions are found after the main subscriptions routed, so they process the new messages right away. // partitions without messages in their inbox have nothing to do while their main subscription boots. - $results = [ - ...$results, - ...$this->claim( - new SubscriptionCriteria( - ids: $command->ids, - groups: $command->groups, - status: [Status::Booting], - partitions: true, - pendingPartitionsOnly: true, - ), - $claimGroups, - $command, - ), - ]; + $partitions = new SubscriptionCriteria( + ids: $command->ids, + groups: $command->groups, + status: [Status::Booting], + partitions: true, + pendingPartitionsOnly: true, + ); + + // a partition processes a limited part of its inbox at a time, without a limit the boot goes on until it is done + do { + $pass = ProcessedResult::merge($this->claim($partitions, $claimGroups, $command)); + $repeat = $command->limit === null && !$pass->finished && $pass->processedMessages > 0; + + // only the last pass tells whether the partitions are done + $results[] = $repeat ? new ProcessedResult($pass->processedMessages, true, $pass->errors) : $pass; + } while ($repeat); return ProcessedResult::merge([...$results, ...$this->finishPartitions($command, $claimGroups)]); } diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index e3f1ddf9e..4e923a6ac 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -17,6 +17,7 @@ use Patchlevel\EventSourcing\Serializer\DefaultEventSerializer; use Patchlevel\EventSourcing\Store\Criteria\Criteria; use Patchlevel\EventSourcing\Store\Criteria\StreamCriterion; +use Patchlevel\EventSourcing\Store\Header\PlayheadHeader; use Patchlevel\EventSourcing\Store\Header\StreamNameHeader; use Patchlevel\EventSourcing\Store\StreamDoctrineDbalStore; use Patchlevel\EventSourcing\Subscription\Engine\Command\Boot; @@ -42,6 +43,7 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Tests\DbalManager; +use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\NameChanged; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Events\ProfileCreated; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\EphemeralPartitionProjection; use Patchlevel\EventSourcing\Tests\Integration\Subscription\Subscriber\OncePartitionedProjection; @@ -54,6 +56,7 @@ use RuntimeException; use Symfony\Component\EventDispatcher\EventDispatcher; +use function array_chunk; use function array_keys; use function array_map; use function array_values; @@ -678,6 +681,37 @@ public function testFailingBatchFlushOnlyStopsItsPartition(): void } } + /** A boot without a limit processes everything, also a partition with more messages than it reads at a time. */ + public function testBootWithoutLimitProcessesLargeInboxes(): void + { + $projection = new PartitionedProfileProjection(); + $engine = $this->engine($projection); + + $id = ProfileId::generate(); + $messages = []; + + for ($i = 1; $i <= 2500; $i++) { + $messages[] = Message::create($i === 1 ? new ProfileCreated($id, 'name-1') : new NameChanged($id, 'name-' . $i)) + ->withHeader(new StreamNameHeader('profile-' . $id->toString())) + ->withHeader(new PlayheadHeader($i)); + } + + foreach (array_chunk($messages, 500) as $chunk) { + $this->store->save(...$chunk); + } + + $engine->execute(new Setup()); + $result = $engine->execute(new Boot()); + + self::assertInstanceOf(ProcessedResult::class, $result); + self::assertTrue($result->finished); + self::assertCount(2500, $projection->processed[$id->toString()]); + self::assertSame( + Status::Active, + $this->subscriptionStore->get(Subscription::partitionId(self::MAIN, 'profile-' . $id->toString()))->status(), + ); + } + /** @param 'index'|'plain'|'unsupported' $messageLoader */ private function engine( object $subscriber, From fdda55715975254733ef370240aefaf83ff1ced8 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 15:54:47 +0200 Subject: [PATCH 53/57] Count the partitions left to finish instead of loading them The end of boot loaded all booting partitions to check whether there are any, and run looked for active partitions of finished run once subscribers on every run. Both now count them first and only claim if there are any. The index on the subscriber id also covers the status, for these counts and for finding idle partitions. The docs mention that commands like pause or remove load all partitions of a subscriber at once. --- docs/UPGRADE-4.0.md | 4 ++-- docs/subscription.md | 5 +++++ src/Subscription/Engine/Handler/BootHandler.php | 3 ++- src/Subscription/Engine/Handler/RunHandler.php | 5 +++++ src/Subscription/Store/DoctrineSubscriptionStore.php | 4 ++-- .../Subscription/Store/DoctrineSubscriptionStoreTest.php | 2 +- 6 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index 3321b82e5..f62ec98fc 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -509,7 +509,7 @@ ALTER TABLE subscriptions ADD subscriber_id VARCHAR(255) DEFAULT NULL; ALTER TABLE subscriptions ADD partition_key VARCHAR(255) DEFAULT NULL; UPDATE subscriptions SET subscriber_id = id; ALTER TABLE subscriptions ALTER COLUMN subscriber_id SET NOT NULL; -CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, last_saved_at); +CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, status, last_saved_at); CREATE INDEX IDX_SUBSCRIPTIONS_PARTITION_KEY ON subscriptions (partition_key, status); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` @@ -522,7 +522,7 @@ ALTER TABLE subscriptions ADD partition_key VARCHAR(255) CHARACTER SET utf8mb4 C UPDATE subscriptions SET subscriber_id = id; ALTER TABLE subscriptions MODIFY subscriber_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; ALTER TABLE subscriptions MODIFY id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL; -CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, last_saved_at); +CREATE INDEX IDX_SUBSCRIPTIONS_SUBSCRIBER_ID ON subscriptions (subscriber_id, status, last_saved_at); CREATE INDEX IDX_SUBSCRIPTIONS_PARTITION_KEY ON subscriptions (partition_key, status); CREATE TABLE subscriptions_inbox (subscription_id VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL, message_index INT NOT NULL, PRIMARY KEY (subscription_id, message_index)); ``` diff --git a/docs/subscription.md b/docs/subscription.md index 26d6e4773..0fe47191d 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -994,6 +994,11 @@ With [batching](#batching), every partition has its own batch and the partitions Many small partitions therefore mean many small batches. ::: +:::note +Commands like pause, reactivate, remove or refresh load all partitions of a subscriber at once. +With very many keys, keep the number of partitions bounded with the `BucketPartitioner` or `removeIdleAfter`. +::: + #### Removing idle partitions By default a partition is kept forever, also after it processed its inbox. diff --git a/src/Subscription/Engine/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index b34b8f52d..8f9a72cbb 100644 --- a/src/Subscription/Engine/Handler/BootHandler.php +++ b/src/Subscription/Engine/Handler/BootHandler.php @@ -109,7 +109,8 @@ private function finishPartitions(Boot $command, ClaimGroups $claimGroups): arra $criteria = new SubscriptionCriteria(ids: $subscriberIds, status: [Status::Booting], partitions: true); - if ($this->subscriptionManager->find($criteria) === []) { + // counted, not loaded, there can be many partitions + if ($this->subscriptionManager->stats($criteria)->total() === 0) { return []; } diff --git a/src/Subscription/Engine/Handler/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index e75243fc5..4d104f8e4 100644 --- a/src/Subscription/Engine/Handler/RunHandler.php +++ b/src/Subscription/Engine/Handler/RunHandler.php @@ -107,6 +107,11 @@ private function finishPartitions(Run $command, ClaimGroups $claimGroups): array $criteria = new SubscriptionCriteria(ids: $subscriberIds, status: [Status::Active], partitions: true); + // counted on every run, usually all partitions of a finished subscriber are finished as well + if ($this->subscriptionManager->stats($criteria)->total() === 0) { + return []; + } + // a partition held by another worker is not claimed, every run checks again return $this->process($criteria, $claimGroups, $command); } diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 8e9e83a85..fe9f17ad5 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -979,8 +979,8 @@ public function configureSchema(Schema $schema, Connection $connection): void $table->addIndex(['status']); // finds the main subscriptions without reading the partitions $table->addIndex(['partition_key', 'status']); - // also finds the idle partitions of a subscriber - $table->addIndex(['subscriber_id', 'last_saved_at']); + // the partitions of a subscriber, also by status and the idle ones + $table->addIndex(['subscriber_id', 'status', 'last_saved_at']); $inbox = $schema->createTable($this->inboxTableName); diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index 64b97c85f..47bbd06db 100644 --- a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php @@ -612,7 +612,7 @@ public function testConfigureSchema(): void $table->addIndex(['group_name']); $table->addIndex(['status']); $table->addIndex(['partition_key', 'status']); - $table->addIndex(['subscriber_id', 'last_saved_at']); + $table->addIndex(['subscriber_id', 'status', 'last_saved_at']); $inbox = $expectedSchema->createTable('subscriptions_inbox'); $inbox->addColumn('subscription_id', Types::STRING) From e57cd5d1d71ac21c00ed34b5e6bea14be0ff08fd Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 16:00:47 +0200 Subject: [PATCH 54/57] Match the tag prefix of TagPartitioner like EventTag does new TagPartitioner('course') now matches course:{id} only, the same tags #[EventTag(prefix: 'course')] creates. Before, 'course' also matched courseware:7 and an empty prefix matched every tag. --- docs/subscription.md | 4 ++-- .../Partitioner/HashedPartitioner.php | 2 +- src/Subscription/Partitioner/TagPartitioner.php | 15 +++++++++------ .../Partitioner/TagPartitionerTest.php | 15 +++++++++++---- 4 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/subscription.md b/docs/subscription.md index 0fe47191d..d2c8738e1 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -903,8 +903,8 @@ final class ProfileProjector #### Built-in partitioners * `StreamPartitioner`: one partition per stream, e.g. per aggregate. -* `TagPartitioner`: the tag with a prefix is the key, e.g. `new TagPartitioner('course:')` for the tags of the - dynamic consistency boundary. A message needs exactly one tag with the prefix. +* `TagPartitioner`: the tag with a prefix is the key, e.g. `new TagPartitioner('course')` for the `course:{id}` tags + of `#[EventTag(prefix: 'course')]`. A message needs exactly one tag with the prefix. * `BucketPartitioner`: spreads the keys of another partitioner over a fixed number of partitions, e.g. `new BucketPartitioner(new StreamPartitioner(), 16)`. Everything with the same key stays in the same bucket. * `HashedPartitioner`: hashes the key of another partitioner, so it is always short and any characters are fine. diff --git a/src/Subscription/Partitioner/HashedPartitioner.php b/src/Subscription/Partitioner/HashedPartitioner.php index 4fccf6df0..3d0a37275 100644 --- a/src/Subscription/Partitioner/HashedPartitioner.php +++ b/src/Subscription/Partitioner/HashedPartitioner.php @@ -9,7 +9,7 @@ use function hash; /** - * Hashes the key of another partitioner, e.g. `new HashedPartitioner(new TagPartitioner('email:'))`. + * Hashes the key of another partitioner, e.g. `new HashedPartitioner(new TagPartitioner('email'))`. * The key always has the same short length, and keys that only differ in case or contain any characters are fine. */ final class HashedPartitioner implements Partitioner diff --git a/src/Subscription/Partitioner/TagPartitioner.php b/src/Subscription/Partitioner/TagPartitioner.php index e2ca96466..fe6d57410 100644 --- a/src/Subscription/Partitioner/TagPartitioner.php +++ b/src/Subscription/Partitioner/TagPartitioner.php @@ -14,11 +14,12 @@ use function str_starts_with; /** - * The tag with the given prefix is the key, e.g. `new TagPartitioner('course:')` for the tags of the dynamic - * consistency boundary. A message needs exactly one tag with the prefix. + * The tag with the given prefix is the key, e.g. `new TagPartitioner('course')` for the `course:{id}` tags of + * `#[EventTag(prefix: 'course')]`. A message needs exactly one tag with the prefix. */ final class TagPartitioner implements Partitioner { + /** @param non-empty-string $prefix */ public function __construct( private readonly string $prefix, ) { @@ -26,23 +27,25 @@ public function __construct( public function __invoke(Message $message): string { + $prefix = $this->prefix . ':'; + try { $tags = $message->header(TagsHeader::class)->tags; } catch (HeaderNotFound) { - throw TagNotUnique::missing($this->prefix); + throw TagNotUnique::missing($prefix); } $matching = array_values(array_filter( $tags, - fn (string $tag): bool => str_starts_with($tag, $this->prefix), + static fn (string $tag): bool => str_starts_with($tag, $prefix), )); if ($matching === []) { - throw TagNotUnique::missing($this->prefix); + throw TagNotUnique::missing($prefix); } if (count($matching) > 1) { - throw TagNotUnique::ambiguous($this->prefix, $matching); + throw TagNotUnique::ambiguous($prefix, $matching); } return $matching[0]; diff --git a/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php b/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php index 092739301..507d99012 100644 --- a/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php +++ b/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php @@ -19,7 +19,7 @@ final class TagPartitionerTest extends TestCase { public function testTagWithThePrefix(): void { - $partitioner = new TagPartitioner('course:'); + $partitioner = new TagPartitioner('course'); self::assertSame('course:42', $partitioner($this->message(['student:1', 'course:42']))); } @@ -28,14 +28,21 @@ public function testNoTagWithThePrefix(): void { $this->expectException(TagNotUnique::class); - (new TagPartitioner('course:'))($this->message(['student:1'])); + (new TagPartitioner('course'))($this->message(['student:1'])); + } + + public function testTagWithALongerName(): void + { + $this->expectException(TagNotUnique::class); + + (new TagPartitioner('course'))($this->message(['courseware:7'])); } public function testNoTags(): void { $this->expectException(TagNotUnique::class); - (new TagPartitioner('course:'))(Message::create(new ProfileVisited(ProfileId::fromString('1')))); + (new TagPartitioner('course'))(Message::create(new ProfileVisited(ProfileId::fromString('1')))); } public function testSeveralTagsWithThePrefix(): void @@ -43,7 +50,7 @@ public function testSeveralTagsWithThePrefix(): void $this->expectException(TagNotUnique::class); $this->expectExceptionMessage('course:1, course:2'); - (new TagPartitioner('course:'))($this->message(['course:1', 'course:2'])); + (new TagPartitioner('course'))($this->message(['course:1', 'course:2'])); } /** @param list $tags */ From 98afc2059db78fa2c492abd91d6b26818c0dbe53 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 16:06:17 +0200 Subject: [PATCH 55/57] Reject subscriber ids that collide with partition ids A subscriber with the id foo#x next to the partitioned subscriber foo made the creation of the partition x fail at flush, again in every run. The subscriber repository now rejects such ids right away. --- docs/subscription.md | 2 +- .../MetadataSubscriberAccessorRepository.php | 20 ++++++++++ .../SubscriberIdCollidesWithPartition.php | 21 ++++++++++ ...tadataSubscriberAccessorRepositoryTest.php | 38 +++++++++++++++++++ 4 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php diff --git a/docs/subscription.md b/docs/subscription.md index d2c8738e1..9577ad5a7 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -976,7 +976,7 @@ Give all events of an aggregate the same key if your projection relies on their :::note The partition key must be a non-empty string and the partition id must not exceed 255 bytes. -The id of a partitioned subscriber must not contain `#`. +The id of a partitioned subscriber `foo` must not contain `#`, and no other subscriber id may start with `foo#`. ::: :::tip diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php b/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php index cba34b27e..c890552ce 100644 --- a/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessorRepository.php @@ -6,9 +6,11 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadataFactory; +use Patchlevel\EventSourcing\Subscription\Subscription; use function array_key_exists; use function array_values; +use function str_starts_with; final class MetadataSubscriberAccessorRepository implements SubscriberAccessorRepository { @@ -55,6 +57,24 @@ private function subscriberAccessorMap(): array ); } + $this->assertNoCollisionWithPartitions(); + return $this->subscribersMap; } + + /** A partition id is "{subscriber id}#{key}", so no other subscriber may have an id like that. */ + private function assertNoCollisionWithPartitions(): void + { + foreach ($this->subscribersMap as $partitionedId => $partitioned) { + if ($partitioned->metadata()->partition === null) { + continue; + } + + foreach ($this->subscribersMap as $id => $subscriber) { + if (str_starts_with($id, $partitionedId . Subscription::PARTITION_SEPARATOR)) { + throw new SubscriberIdCollidesWithPartition($id, $partitionedId); + } + } + } + } } diff --git a/src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php b/src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php new file mode 100644 index 000000000..dea0bfdd2 --- /dev/null +++ b/src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php @@ -0,0 +1,21 @@ +all(); } + + public function testSubscriberIdCollidesWithPartition(): void + { + $this->expectException(SubscriberIdCollidesWithPartition::class); + $this->expectExceptionMessage('"foo#bar"'); + + $subscriber1 = new #[Subscriber('foo#bar', RunMode::FromBeginning)] + class { + }; + + $subscriber2 = new #[Subscriber('foo', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] + class { + }; + + $repository = new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]); + + $repository->all(); + } + + public function testSimilarIdOfAPartitionedSubscriber(): void + { + $subscriber1 = new #[Subscriber('foobar', RunMode::FromBeginning)] + class { + }; + + $subscriber2 = new #[Subscriber('foo', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] + class { + }; + + $repository = new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]); + + self::assertCount(2, $repository->all()); + } } From 212f7cdc673a4b8998c197e7f76aa8e2c8981938 Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 16:11:36 +0200 Subject: [PATCH 56/57] Reserve # for partition ids in every subscriber id Instead of checking the ids of all subscribers against each other in the repository, #[Subscriber] now rejects any id with #. A subscriber id can no longer collide with the id of a partition, also with a custom subscriber repository. --- docs/UPGRADE-4.0.md | 1 + docs/subscription.md | 2 +- src/Attribute/InvalidSubscriberId.php | 19 ++++++++++ src/Attribute/Subscriber.php | 6 +++ .../AttributeSubscriberMetadataFactory.php | 6 --- .../InvalidPartitionedSubscriberId.php | 26 ------------- .../MetadataSubscriberAccessorRepository.php | 20 ---------- .../SubscriberIdCollidesWithPartition.php | 21 ---------- tests/Unit/Attribute/SubscriberTest.php | 9 +++++ ...AttributeSubscriberMetadataFactoryTest.php | 15 +------- ...tadataSubscriberAccessorRepositoryTest.php | 38 ------------------- 11 files changed, 37 insertions(+), 126 deletions(-) create mode 100644 src/Attribute/InvalidSubscriberId.php delete mode 100644 src/Metadata/Subscriber/InvalidPartitionedSubscriberId.php delete mode 100644 src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index f62ec98fc..e263cb1a6 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -500,6 +500,7 @@ subscriptions keep processing. #### Subscription partitions Subscribers can be split into partitions with `#[Partition]`, see the subscription docs. +A subscriber id must no longer contain `#`, it is reserved for the ids of the partitions. The subscription table gets the columns `subscriber_id` and `partition_key`, and there is a new table `subscriptions_inbox` (or `{table name}_inbox`). Since `subscriber_id` is not nullable, fill it before making it `NOT NULL`, otherwise the migration fails on existing rows: diff --git a/docs/subscription.md b/docs/subscription.md index 9577ad5a7..d94ee5ef1 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -976,7 +976,7 @@ Give all events of an aggregate the same key if your projection relies on their :::note The partition key must be a non-empty string and the partition id must not exceed 255 bytes. -The id of a partitioned subscriber `foo` must not contain `#`, and no other subscriber id may start with `foo#`. +A subscriber id must not contain `#`, it is reserved for the ids of the partitions. ::: :::tip diff --git a/src/Attribute/InvalidSubscriberId.php b/src/Attribute/InvalidSubscriberId.php new file mode 100644 index 000000000..44dd8eab2 --- /dev/null +++ b/src/Attribute/InvalidSubscriberId.php @@ -0,0 +1,19 @@ +id, Subscription::PARTITION_SEPARATOR)) { - throw new InvalidPartitionedSubscriberId($subscriber, $subscriberInfo->id); - } - $partition = $partitionAttributes[0]->newInstance(); $partitionMetadata = new PartitionMetadata( $partition->partitioner, diff --git a/src/Metadata/Subscriber/InvalidPartitionedSubscriberId.php b/src/Metadata/Subscriber/InvalidPartitionedSubscriberId.php deleted file mode 100644 index 9c37d0bda..000000000 --- a/src/Metadata/Subscriber/InvalidPartitionedSubscriberId.php +++ /dev/null @@ -1,26 +0,0 @@ -assertNoCollisionWithPartitions(); - return $this->subscribersMap; } - - /** A partition id is "{subscriber id}#{key}", so no other subscriber may have an id like that. */ - private function assertNoCollisionWithPartitions(): void - { - foreach ($this->subscribersMap as $partitionedId => $partitioned) { - if ($partitioned->metadata()->partition === null) { - continue; - } - - foreach ($this->subscribersMap as $id => $subscriber) { - if (str_starts_with($id, $partitionedId . Subscription::PARTITION_SEPARATOR)) { - throw new SubscriberIdCollidesWithPartition($id, $partitionedId); - } - } - } - } } diff --git a/src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php b/src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php deleted file mode 100644 index dea0bfdd2..000000000 --- a/src/Subscription/Subscriber/SubscriberIdCollidesWithPartition.php +++ /dev/null @@ -1,21 +0,0 @@ -id); } + + public function testIdWithPartitionSeparator(): void + { + $this->expectException(InvalidSubscriberId::class); + + new Subscriber('foo#bar', RunMode::FromBeginning); + } } diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index 0358b924a..e54e6b670 100644 --- a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php +++ b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php @@ -37,7 +37,6 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateSubscribeMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateTeardownMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\IncompleteBatchMethods; -use Patchlevel\EventSourcing\Metadata\Subscriber\InvalidPartitionedSubscriberId; use Patchlevel\EventSourcing\Metadata\Subscriber\MixedTeardownAndCleanupMethods; use Patchlevel\EventSourcing\Metadata\Subscriber\PartitionMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; @@ -839,7 +838,7 @@ class { public function testNoPartition(): void { - $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] class { }; @@ -848,16 +847,4 @@ class { self::assertNull($metadata->partition); } - - public function testPartitionedSubscriberIdWithSeparator(): void - { - $this->expectException(InvalidPartitionedSubscriberId::class); - - $subscriber = new #[Subscriber('foo#bar', RunMode::FromBeginning)] - #[Partition(new StreamPartitioner())] - class { - }; - - (new AttributeSubscriberMetadataFactory())->metadata($subscriber::class); - } } diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php index cab7eb41a..b80aa9bc5 100644 --- a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php +++ b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorRepositoryTest.php @@ -4,15 +4,12 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Subscription\Subscriber; -use Patchlevel\EventSourcing\Attribute\Partition; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Metadata\Subscriber\AttributeSubscriberMetadataFactory; -use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Subscriber\DuplicateSubscriberId; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessor; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; -use Patchlevel\EventSourcing\Subscription\Subscriber\SubscriberIdCollidesWithPartition; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -71,39 +68,4 @@ class { $repository->all(); } - - public function testSubscriberIdCollidesWithPartition(): void - { - $this->expectException(SubscriberIdCollidesWithPartition::class); - $this->expectExceptionMessage('"foo#bar"'); - - $subscriber1 = new #[Subscriber('foo#bar', RunMode::FromBeginning)] - class { - }; - - $subscriber2 = new #[Subscriber('foo', RunMode::FromBeginning)] - #[Partition(new StreamPartitioner())] - class { - }; - - $repository = new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]); - - $repository->all(); - } - - public function testSimilarIdOfAPartitionedSubscriber(): void - { - $subscriber1 = new #[Subscriber('foobar', RunMode::FromBeginning)] - class { - }; - - $subscriber2 = new #[Subscriber('foo', RunMode::FromBeginning)] - #[Partition(new StreamPartitioner())] - class { - }; - - $repository = new MetadataSubscriberAccessorRepository([$subscriber1, $subscriber2]); - - self::assertCount(2, $repository->all()); - } } From ad471caf702e5288283a7006a7949fbef2d18e0d Mon Sep 17 00:00:00 2001 From: David Badura Date: Thu, 15 Oct 2026 16:35:59 +0200 Subject: [PATCH 57/57] Count the messages waiting in the inboxes of the partitions PartitionInboxStore gets inboxStats(), which counts the messages after the position of the matching partitions and in how many partitions they wait. The stats of the engine carry it if the store supports partitions, and the status command shows it next to the number of partitions. --- docs/UPGRADE-4.0.md | 4 +-- docs/cli.md | 5 +-- docs/subscription.md | 12 +++++++ .../Command/SubscriptionStatusCommand.php | 13 +++++--- .../Engine/DefaultSubscriptionEngine.php | 17 +++++----- .../Engine/SubscriptionManager.php | 11 +++++++ .../Store/DoctrineSubscriptionStore.php | 24 ++++++++++++++ .../Store/InMemorySubscriptionStore.php | 22 +++++++++++++ src/Subscription/Store/InboxStats.php | 17 ++++++++++ .../Store/PartitionInboxStore.php | 3 ++ src/Subscription/Store/SubscriptionStats.php | 2 ++ .../DoctrineSubscriptionStoreTest.php | 31 +++++++++++++++++++ .../PartitionedSubscriptionTest.php | 9 +++--- .../Command/FakeSubscriptionEngine.php | 5 ++- .../Command/SubscriptionStatusCommandTest.php | 5 ++- .../Subscription/DummySubscriptionStore.php | 6 ++++ .../Store/InMemorySubscriptionStoreTest.php | 29 +++++++++++++++++ 17 files changed, 194 insertions(+), 21 deletions(-) create mode 100644 src/Subscription/Store/InboxStats.php diff --git a/docs/UPGRADE-4.0.md b/docs/UPGRADE-4.0.md index e263cb1a6..af8258991 100644 --- a/docs/UPGRADE-4.0.md +++ b/docs/UPGRADE-4.0.md @@ -531,8 +531,8 @@ Adjust the table names if you configured your own. A custom `SubscriptionStore` has to persist `partitionKey()` and `subscriberId()` and support the new `partitions` and `pendingPartitionsOnly` criteria. To support partitions, it also has to implement the -`PartitionInboxStore` interface with `addToInbox()` and `inbox()`. The inbox of a partition only holds messages after its -position: updating a partition clears its inbox up to its position, removing it removes its inbox. +`PartitionInboxStore` interface with `addToInbox()`, `inbox()`, `inboxStats()` and `removeIdlePartitions()`. +The inbox of a partition only holds messages after its position: updating a partition clears its inbox up to its position, removing it removes its inbox. A store without partitions support throws `PartitionsNotSupported` as soon as a partitioned subscriber is processed. The `ids` of `SubscriptionCriteria` now match the subscription diff --git a/docs/cli.md b/docs/cli.md index 6715b3c10..c3986794a 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -240,8 +240,9 @@ If the subscription has an error, the command also prints the error message and ```bash bin/console event-sourcing:subscription:status profile_1 ``` -For a partitioned subscriber, the table shows the number of partitions and how many are not active, -e.g. `1204 (error: 2, booting: 5)`. The partitions are counted, not loaded. +For a partitioned subscriber, the table shows the number of partitions, how many are not active +and how many messages wait in how many inboxes, e.g. `1204 (error: 2, booting: 5), inbox: 3500 in 120`. +The partitions are counted, not loaded. Use `--partitions` to list them, and pass the id of a partition to show its details. ```bash diff --git a/docs/subscription.md b/docs/subscription.md index d94ee5ef1..1d8ac2912 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -1894,7 +1894,19 @@ $stats = $subscriptionEngine->stats(new SubscriptionEngineCriteria(['profile_1'] echo $stats->total(); echo $stats->count(Status::Error); ``` +If the store supports partitions, `inbox` holds how many messages wait in the inboxes of the matching partitions +and in how many partitions, otherwise it is `null`. +```php +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; +use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; + +/** @var SubscriptionEngine $subscriptionEngine */ +$stats = $subscriptionEngine->stats(new SubscriptionEngineCriteria(['profile_1'], partitions: true)); + +echo $stats->inbox?->messages; +echo $stats->inbox?->partitions; +``` ## Learn more * [How to use CLI commands](cli.md) diff --git a/src/Console/Command/SubscriptionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php index 6d6d38b9e..01762e05d 100644 --- a/src/Console/Command/SubscriptionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -172,7 +172,10 @@ private function displayError(OutputStyle $io, array $context): void } } - /** The number of partitions and how many of them are not active, e.g. "1204 (error: 2, booting: 5)". */ + /** + * The number of partitions, how many of them are not active and how many messages wait in how many inboxes, + * e.g. "1204 (error: 2, booting: 5), inbox: 3500 in 120". + */ private static function partitionSummary(SubscriptionStats $stats): string|null { $total = $stats->total(); @@ -191,11 +194,13 @@ private static function partitionSummary(SubscriptionStats $stats): string|null $parts[] = sprintf('%s: %d', $status, $count); } - if ($parts === []) { - return (string)$total; + $summary = $parts === [] ? (string)$total : sprintf('%d (%s)', $total, implode(', ', $parts)); + + if ($stats->inbox === null || $stats->inbox->messages === 0) { + return $summary; } - return sprintf('%d (%s)', $total, implode(', ', $parts)); + return sprintf('%s, inbox: %d in %d', $summary, $stats->inbox->messages, $stats->inbox->partitions); } /** @param list $subscriptions */ diff --git a/src/Subscription/Engine/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index e8b955920..cca4f2181 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -281,13 +281,16 @@ public function stats(SubscriptionEngineCriteria|null $criteria = null): Subscri { $criteria ??= new SubscriptionEngineCriteria(); - return $this->subscriptionManager->stats( - new SubscriptionCriteria( - ids: $criteria->ids, - groups: $criteria->groups, - status: $criteria->status, - partitions: $criteria->partitions, - ), + $subscriptionCriteria = new SubscriptionCriteria( + ids: $criteria->ids, + groups: $criteria->groups, + status: $criteria->status, + partitions: $criteria->partitions, + ); + + return new SubscriptionStats( + $this->subscriptionManager->stats($subscriptionCriteria)->status, + $this->subscriptionManager->inboxStats($subscriptionCriteria), ); } } diff --git a/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index 9fcaaf783..6cb6a2b05 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -5,6 +5,7 @@ namespace Patchlevel\EventSourcing\Subscription\Engine; use Closure; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\PartitionInboxStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; @@ -332,6 +333,16 @@ public function stats(SubscriptionCriteria $criteria): SubscriptionStats return $this->subscriptionStore->stats($criteria); } + /** Null if the store has no partitions support. */ + public function inboxStats(SubscriptionCriteria $criteria): InboxStats|null + { + if (!$this->subscriptionStore instanceof PartitionInboxStore) { + return null; + } + + return $this->subscriptionStore->inboxStats($criteria); + } + public function add(Subscription ...$subscriptions): void { foreach ($subscriptions as $sub) { diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index fe9f17ad5..330e20ab1 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -272,6 +272,30 @@ public function stats(SubscriptionCriteria $criteria): SubscriptionStats return new SubscriptionStats($status); } + public function inboxStats(SubscriptionCriteria $criteria): InboxStats + { + $qb = $this->connection->createQueryBuilder() + ->select('COUNT(*) AS messages', sprintf('COUNT(DISTINCT %s.subscription_id) AS partitions', $this->inboxTableName)) + ->from($this->tableName) + ->innerJoin( + $this->tableName, + $this->inboxTableName, + $this->inboxTableName, + sprintf( + '%1$s.subscription_id = %2$s.id AND %1$s.message_index > %2$s.position', + $this->inboxTableName, + $this->tableName, + ), + ); + + $this->applyCriteria($qb, $criteria); + + /** @var array{messages: int|string, partitions: int|string} $row */ + $row = $qb->fetchAssociative(); + + return new InboxStats((int)$row['messages'], (int)$row['partitions']); + } + /** * @param non-empty-list $ids * diff --git a/src/Subscription/Store/InMemorySubscriptionStore.php b/src/Subscription/Store/InMemorySubscriptionStore.php index 1e173b09b..9669ab178 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -14,10 +14,13 @@ use function array_keys; use function array_slice; use function array_values; +use function count; use function in_array; use function sort; use function sprintf; +use const PHP_INT_MAX; + final class InMemorySubscriptionStore implements PartitionInboxStore { /** @var array */ @@ -232,6 +235,25 @@ public function inbox(array $afterIndex, int $limit): array return $inbox; } + public function inboxStats(SubscriptionCriteria $criteria): InboxStats + { + $messages = 0; + $partitions = 0; + + foreach ($this->find($criteria) as $subscription) { + $count = count($this->inbox([$subscription->id() => $subscription->position() ?? -1], PHP_INT_MAX)[$subscription->id()] ?? []); + + if ($count === 0) { + continue; + } + + $messages += $count; + $partitions++; + } + + return new InboxStats($messages, $partitions); + } + /** * @param positive-int $idleSeconds * @param positive-int $limit diff --git a/src/Subscription/Store/InboxStats.php b/src/Subscription/Store/InboxStats.php new file mode 100644 index 000000000..4b5c559d7 --- /dev/null +++ b/src/Subscription/Store/InboxStats.php @@ -0,0 +1,17 @@ +, int> $status number of subscriptions per status, statuses without any are left out */ public function __construct( public readonly array $status = [], + /** the inboxes of the matching partitions, null if the store has no partitions support */ + public readonly InboxStats|null $inbox = null, ) { } diff --git a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index f8b2165db..ef1aa4b03 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -17,6 +17,7 @@ use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionAlreadyExists; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; @@ -1283,6 +1284,36 @@ public function testStats(): void self::assertSame(1, $store->stats(new SubscriptionCriteria(status: [Status::Paused]))->total()); } + public function testInboxStats(): void + { + $store = $this->createStore(); + + $main = new Subscription('profile', status: Status::Active, position: 10); + $broken = Subscription::partition($main, 'broken', 3); + $broken->error('database down'); + + $store->add( + $main, + Subscription::partition($main, 'a', 5), + Subscription::partition($main, 'b', 6), + $broken, + new Subscription('other', status: Status::Paused), + ); + $store->addToInbox([ + 'profile#a' => [6, 7], + 'profile#b' => [6, 8], + 'profile#broken' => [9], + ]); + + self::assertEquals( + new InboxStats(4, 3), + $store->inboxStats(new SubscriptionCriteria(ids: ['profile'], partitions: true)), + 'messages up to the position of a partition are not counted', + ); + self::assertEquals(new InboxStats(1, 1), $store->inboxStats(new SubscriptionCriteria(status: [Status::Error]))); + self::assertEquals(new InboxStats(), $store->inboxStats(new SubscriptionCriteria(ids: ['other']))); + } + public function testClaimAll(): void { $store = $this->createStore(); diff --git a/tests/Integration/Subscription/PartitionedSubscriptionTest.php b/tests/Integration/Subscription/PartitionedSubscriptionTest.php index 4e923a6ac..10c02c5a2 100644 --- a/tests/Integration/Subscription/PartitionedSubscriptionTest.php +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -39,6 +39,7 @@ use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -120,10 +121,10 @@ public function testRoutesEveryMessageIntoThePartitionOfItsKey(): void } self::assertSame(3, $projection->handled, 'only the partitions call the subscriber, every message once'); - self::assertSame( - ['active' => 3], - $engine->stats(new SubscriptionEngineCriteria([self::MAIN], partitions: true))->status, - ); + $stats = $engine->stats(new SubscriptionEngineCriteria([self::MAIN], partitions: true)); + + self::assertSame(['active' => 3], $stats->status); + self::assertEquals(new InboxStats(0, 0), $stats->inbox, 'nothing left in the inboxes'); foreach ($this->partitions() as $partition) { self::assertSame([], $this->subscriptionStore->inbox([$partition->id() => -1], 10), 'processed messages leave the inbox'); diff --git a/tests/Unit/Console/Command/FakeSubscriptionEngine.php b/tests/Unit/Console/Command/FakeSubscriptionEngine.php index f964c69fb..0792c48ef 100644 --- a/tests/Unit/Console/Command/FakeSubscriptionEngine.php +++ b/tests/Unit/Console/Command/FakeSubscriptionEngine.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\Result; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine; use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -44,6 +45,8 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): /** @var list */ public array $statsCriteria = []; + public InboxStats|null $inbox = null; + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats { $criteria ??= new SubscriptionEngineCriteria(); @@ -55,7 +58,7 @@ public function stats(SubscriptionEngineCriteria|null $criteria = null): Subscri $status[$subscription->status()->value] = ($status[$subscription->status()->value] ?? 0) + 1; } - return new SubscriptionStats($status); + return new SubscriptionStats($status, $this->inbox); } /** @return list */ diff --git a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php index 6806d9d9c..ec48d7e19 100644 --- a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -8,6 +8,7 @@ use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngineCriteria; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; @@ -77,12 +78,14 @@ public function testPartitionSummaryWithoutLoadingThePartitions(): void new Subscription('plain', status: Status::Active, position: 3), ]); + $engine->inbox = new InboxStats(7, 2); + $commandTester = new CommandTester(new SubscriptionStatusCommand($engine)); $commandTester->execute([]); $display = $commandTester->getDisplay(); - self::assertStringContainsString('3 (error: 1)', $display); + self::assertStringContainsString('3 (error: 1), inbox: 7 in 2', $display); self::assertStringNotContainsString('└', $display, 'the partitions are only listed with --partitions'); self::assertEquals( [new SubscriptionEngineCriteria(partitions: false)], diff --git a/tests/Unit/Subscription/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 01c24f060..a56768b3c 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -7,6 +7,7 @@ use Closure; use DateTimeImmutable; use Patchlevel\EventSourcing\Clock\FrozenClock; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\PartitionInboxStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; @@ -127,6 +128,11 @@ public function inbox(array $afterIndex, int $limit): array return $this->parentStore->inbox($afterIndex, $limit); } + public function inboxStats(SubscriptionCriteria $criteria): InboxStats + { + return $this->parentStore->inboxStats($criteria); + } + /** * @param positive-int $idleSeconds * @param positive-int $limit diff --git a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php index abf400235..077a7fe76 100644 --- a/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php +++ b/tests/Unit/Subscription/Store/InMemorySubscriptionStoreTest.php @@ -7,6 +7,7 @@ use DateTimeImmutable; use Patchlevel\EventSourcing\Clock\FrozenClock; use Patchlevel\EventSourcing\Subscription\Status; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\InMemorySubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionAlreadyExists; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; @@ -119,6 +120,34 @@ public function testStats(): void self::assertSame(['active' => 2, 'error' => 1, 'paused' => 1], $store->stats(new SubscriptionCriteria())->status); } + public function testInboxStats(): void + { + $main = new Subscription('profile', status: Status::Active, position: 10); + $broken = Subscription::partition($main, 'broken', 3); + $broken->error('database down'); + + $store = new InMemorySubscriptionStore([ + $main, + Subscription::partition($main, 'a', 5), + Subscription::partition($main, 'b', 6), + $broken, + new Subscription('other', status: Status::Paused), + ]); + $store->addToInbox([ + 'profile#a' => [6, 7], + 'profile#b' => [6, 8], + 'profile#broken' => [9], + ]); + + self::assertEquals( + new InboxStats(4, 3), + $store->inboxStats(new SubscriptionCriteria(ids: ['profile'], partitions: true)), + 'messages up to the position of a partition are not counted', + ); + self::assertEquals(new InboxStats(1, 1), $store->inboxStats(new SubscriptionCriteria(status: [Status::Error]))); + self::assertEquals(new InboxStats(), $store->inboxStats(new SubscriptionCriteria(ids: ['other']))); + } + public function testFind(): void { $subscription1 = new Subscription('1');