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 b288215d6..af8258991 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 @@ -423,22 +427,37 @@ 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; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; interface SubscriptionStore { - // ... get/find/add/update/remove ... + // ... get/find ... + + /** Count the matching subscriptions per status, without loading them. */ + public function stats(SubscriptionCriteria $criteria): SubscriptionStats; /** - * 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. 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(string $id, SubscriptionCriteria $criteria): Subscription|null; + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array; + + public function add(Subscription ...$subscriptions): void; + + public function update(Subscription ...$subscriptions): void; + + public function remove(Subscription ...$subscriptions): void; /** * @param Closure():T $closure @@ -453,14 +472,22 @@ 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. + #### Message limit is now per subscription 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 -checkpoint/lock-hold granularity: a unit of at most `limit` messages commits atomically and releases -the lock afterwards. +CLI default of `message-limit=100` now means "100 per subscription". +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 @@ -470,6 +497,48 @@ 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. +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: + +```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, 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)); +``` +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, 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)); +``` +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()`, `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 +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..c3986794a 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 @@ -238,6 +240,15 @@ 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, 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 +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/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/docs/subscription.md b/docs/subscription.md index d2388bf9e..1d8ac2912 100644 --- a/docs/subscription.md +++ b/docs/subscription.md @@ -313,6 +313,29 @@ 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; + +#[Projector('profile_1')] +#[Partition(new TenantPartitioner())] +final class ProfileProjector +{ + #[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 @@ -321,6 +344,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; @@ -848,6 +872,183 @@ 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 `#[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\Subscription\Partitioner\StreamPartitioner; + +#[Projector('profile_1')] +#[Partition(new StreamPartitioner())] +final class ProfileProjector +{ + #[Subscribe(ProfileCreated::class)] + public function onProfileCreated(ProfileCreated $event): void + { + // ... + } +} +``` +#### 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 `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. + +```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. +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}` +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. +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. +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. +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. +A subscriber id must not contain `#`, it is reserved for the ids of the partitions. +::: + +:::tip +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. +::: + +:::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. +::: + +:::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. +::: + +:::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. +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\Subscription\Partitioner\StreamPartitioner; + +#[Projector('profile_1')] +#[Partition(new StreamPartitioner(), removeIdleAfter: 3600)] +final class ProfileProjector +{ + // ... +} +``` +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 +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\Subscription\Partitioner\StreamPartitioner; + +#[Projector('profile_1')] +#[Partition(new StreamPartitioner(), maxConsecutiveFailures: 20)] +final class ProfileProjector +{ + // ... +} +``` +:::note +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 The subscription engine manages individual subscribers and keeps the subscriptions running. @@ -1375,12 +1576,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; @@ -1560,6 +1766,34 @@ $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). + +### 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. +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; +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. +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. +Large groups make the work spread worse over several workers. +::: :::note This requires a database that supports `SKIP LOCKED` (PostgreSQL and MySQL do). SQLite serializes @@ -1644,6 +1878,35 @@ 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. + +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); +``` +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/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/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 @@ +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/SubscriptionCommand.php b/src/Console/Command/SubscriptionCommand.php index b54468316..6a7a32a92 100644 --- a/src/Console/Command/SubscriptionCommand.php +++ b/src/Console/Command/SubscriptionCommand.php @@ -50,7 +50,10 @@ 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), + ); return new SubscriptionEngineCriteria( array_map( 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/Console/Command/SubscriptionStatusCommand.php b/src/Console/Command/SubscriptionStatusCommand.php index 648de136a..01762e05d 100644 --- a/src/Console/Command/SubscriptionStatusCommand.php +++ b/src/Console/Command/SubscriptionStatusCommand.php @@ -7,15 +7,18 @@ 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\Store\SubscriptionStats; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use function array_map; +use function implode; use function is_array; use function sprintf; @@ -30,11 +33,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 @@ -44,7 +54,58 @@ 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, 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 = []; + + foreach ($partitionList as $partition) { + $partitions[$partition->subscriberId()][] = $partition; + } + + $rows = []; + + foreach ($subscriptions as $subscription) { + $rows[] = [ + $subscription->id(), + $subscription->group(), + $subscription->runMode()->value, + $subscription->position(), + $subscription->status()->value, + self::partitionSummary($this->engine->stats( + new SubscriptionEngineCriteria([$subscription->id()], partitions: true), + )), + $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,26 +114,19 @@ 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; } + // 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, ); @@ -118,6 +172,37 @@ private function displayError(OutputStyle $io, array $context): void } } + /** + * 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(); + + if ($total === 0) { + return null; + } + + $parts = []; + + foreach ($stats->status as $status => $count) { + if ($status === Status::Active->value) { + continue; + } + + $parts[] = sprintf('%s: %d', $status, $count); + } + + $summary = $parts === [] ? (string)$total : sprintf('%d (%s)', $total, implode(', ', $parts)); + + if ($stats->inbox === null || $stats->inbox->messages === 0) { + return $summary; + } + + return sprintf('%s, inbox: %d in %d', $summary, $stats->inbox->messages, $stats->inbox->partitions); + } + /** @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..ec235315b 100644 --- a/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php +++ b/src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.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\RetryStrategy; use Patchlevel\EventSourcing\Attribute\Setup; use Patchlevel\EventSourcing\Attribute\Subscribe; @@ -232,6 +233,18 @@ public function metadata(string $subscriber): SubscriberMetadata ); } + $partitionAttributes = $reflector->getAttributes(Partition::class); + $partitionMetadata = null; + + if ($partitionAttributes !== []) { + $partition = $partitionAttributes[0]->newInstance(); + $partitionMetadata = new PartitionMetadata( + $partition->partitioner, + $partition->maxConsecutiveFailures, + $partition->removeIdleAfter, + ); + } + $metadata = new SubscriberMetadata( $subscriberInfo->id, $subscriberInfo->group, @@ -245,6 +258,7 @@ public function metadata(string $subscriber): SubscriberMetadata $reflector->getAttributes(EnableEventEmittingDuringBoot::class) !== [], $reflector->getAttributes(DisableEventEmitting::class) !== [], $batch, + $partitionMetadata, ); $this->subscriberMetadata[$subscriber] = $metadata; diff --git a/src/Metadata/Subscriber/PartitionMetadata.php b/src/Metadata/Subscriber/PartitionMetadata.php new file mode 100644 index 000000000..ab092f002 --- /dev/null +++ b/src/Metadata/Subscriber/PartitionMetadata.php @@ -0,0 +1,21 @@ + */ + 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/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/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 @@ +|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/DefaultSubscriptionEngine.php b/src/Subscription/Engine/DefaultSubscriptionEngine.php index ebb6dcf28..cca4f2181 100644 --- a/src/Subscription/Engine/DefaultSubscriptionEngine.php +++ b/src/Subscription/Engine/DefaultSubscriptionEngine.php @@ -31,11 +31,14 @@ 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\PartitionCircuitBreakerSubscriber; +use Patchlevel\EventSourcing\Subscription\Engine\Listener\RemovePartitionsListener; use Patchlevel\EventSourcing\Subscription\Engine\Listener\RetrySubscriber; use Patchlevel\EventSourcing\Subscription\RetryStrategy\ClockBasedRetryStrategy; 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; @@ -167,22 +170,40 @@ public function __construct( ), ); + $failedMessageProcessor = new FailedMessageProcessor( + $this->subscriptionManager, + $this->subscriberRepository, + $this->logger, + ); + $this->eventDispatcher->addSubscriber( - new BatchSubscriber( - $batchManager, + new PartitionCircuitBreakerSubscriber( + $this->subscriptionManager, $this->subscriberRepository, + $this->retryStrategyRepository, + $failedMessageProcessor, $this->logger, ), ); $this->eventDispatcher->addSubscriber( - new FailSubscriber( + new RemovePartitionsListener( $this->subscriptionManager, + $this->eventDispatcher, + $this->logger, + ), + ); + + $this->eventDispatcher->addSubscriber( + new BatchSubscriber( + $batchManager, $this->subscriberRepository, $this->logger, ), ); + $this->eventDispatcher->addSubscriber(new FailSubscriber($failedMessageProcessor)); + $this->eventDispatcher->addListener( OnCommand::class, new DetachListener( @@ -250,7 +271,26 @@ public function subscriptions(SubscriptionEngineCriteria|null $criteria = null): new SubscriptionCriteria( ids: $criteria->ids, groups: $criteria->groups, + status: $criteria->status, + partitions: $criteria->partitions, ), ); } + + public function stats(SubscriptionEngineCriteria|null $criteria = null): SubscriptionStats + { + $criteria ??= new SubscriptionEngineCriteria(); + + $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/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 @@ + $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/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/GapResolverStoreMessageLoader.php b/src/Subscription/Engine/GapResolverStoreMessageLoader.php index 4f1016f55..885e6f392 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; @@ -18,7 +19,7 @@ use function usleep; -final class GapResolverStoreMessageLoader implements MessageLoader +final class GapResolverStoreMessageLoader implements IndexMessageLoader { /** @param list $retriesInMs in milliseconds */ public function __construct( @@ -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/Handler/BootHandler.php b/src/Subscription/Engine/Handler/BootHandler.php index 210f6a85a..8f9a72cbb 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; @@ -15,6 +16,8 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Throwable; +use function array_map; + /** * @internal * @@ -30,25 +33,128 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { - /** @var list $results */ - $results = $this->subscriptionManager->forEachClaimed( + $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, + ); + + 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: false, ), - fn (Subscription $subscription): ProcessedResult => $this->runner->process( - [$subscription], - $command->limit, - boot: true, - ), - static fn (Subscription $subscription, Throwable $e): ProcessedResult => new ProcessedResult( - 0, - false, - [new Error($subscription->id(), $e->getMessage(), $e)], + $claimGroups, + $command, + ); + + // 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. + $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)]); + } + + /** + * 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); + + // counted, not loaded, there can be many partitions + if ($this->subscriptionManager->stats($criteria)->total() === 0) { + return []; + } + + return [ + ...$this->claim( + $criteria, + $claimGroups, + $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 ProcessedResult::merge($results); + return $results; + } + + /** @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, + ), + ); } } diff --git a/src/Subscription/Engine/Handler/PauseHandler.php b/src/Subscription/Engine/Handler/PauseHandler.php index 9a714e6db..105bd30d0 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,25 @@ 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, + )), + wait: true, ); return Result::merge($results); diff --git a/src/Subscription/Engine/Handler/ReactivateHandler.php b/src/Subscription/Engine/Handler/ReactivateHandler.php index 452c82fc6..fb661f09d 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,54 @@ 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, + )), + wait: true, + ); - 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(), + )); } } diff --git a/src/Subscription/Engine/Handler/RefreshHandler.php b/src/Subscription/Engine/Handler/RefreshHandler.php index 46648a80f..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,74 +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(), - ), - ); - } - - $cleanupTasks = $this->cleanupTasks($subscriber); - - if ($subscription->cleanupTasks() !== $cleanupTasks) { - $changed = true; - $subscription->replaceCleanupTasks($cleanupTasks); - - $this->logger?->info( - sprintf( - 'Subscription Engine: Subscription "%s" cleanup tasks changed.', - $subscription->id(), - ), - ); - } - - if (!$changed) { - continue; - } - - $this->subscriptionManager->update($subscription); + // 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 4b1f19414..5232bd666 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()) { @@ -113,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/RunHandler.php b/src/Subscription/Engine/Handler/RunHandler.php index 0a98d1306..4d104f8e4 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; @@ -15,6 +16,8 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Throwable; +use function array_map; + /** * @internal * @@ -30,24 +33,110 @@ public function __construct( public function __invoke(Command $command): ProcessedResult { - /** @var list $results */ - $results = $this->subscriptionManager->forEachClaimed( + $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], + partitions: false, ), - fn (Subscription $subscription): ProcessedResult => $this->runner->process( - [$subscription], + $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, ...$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); + + // 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); + } + + /** @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, ), - 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, + ), ), ); - return ProcessedResult::merge($results); + return $results; } } diff --git a/src/Subscription/Engine/Handler/SetupHandler.php b/src/Subscription/Engine/Handler/SetupHandler.php index 90f3cc840..bc5a40c6a 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(); @@ -99,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 4ab35565f..6329920ae 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()) { @@ -123,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/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/InvalidPartitionKey.php b/src/Subscription/Engine/InvalidPartitionKey.php new file mode 100644 index 000000000..97df17632 --- /dev/null +++ b/src/Subscription/Engine/InvalidPartitionKey.php @@ -0,0 +1,30 @@ +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/DetachListener.php b/src/Subscription/Engine/Listener/DetachListener.php index 638fa018e..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,13 +36,18 @@ public function __invoke(OnCommand $event): void return; } + /** @var list $detached */ + $detached = []; + $this->subscriptionManager->forEachClaimed( new SubscriptionCriteria( 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 { + function (Subscription $subscription) use (&$detached): void { $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); if ($subscriber) { @@ -48,6 +56,7 @@ function (Subscription $subscription): void { $subscription->detached(); $this->subscriptionManager->update($subscription); + $detached[] = $subscription->id(); $this->logger?->info( sprintf( @@ -57,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/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/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/PartitionCircuitBreakerSubscriber.php b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php new file mode 100644 index 000000000..702d046dd --- /dev/null +++ b/src/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriber.php @@ -0,0 +1,362 @@ + 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 = []; + + /** @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, + ) { + } + + public function onCommand(OnCommand $event): void + { + $this->active = $event->command instanceof Run || $event->command instanceof Boot; + $this->failuresInARow = []; + $this->failedPartitions = []; + $this->tripped = []; + $this->succeeded = []; + $this->heldBackFailures = []; + } + + public function onBeforeHandleMessage(OnBeforeHandleMessage $event): void + { + if (!$event->subscription->isPartition() || !isset($this->tripped[$event->subscription->subscriberId()])) { + return; + } + + $event->stop = 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); + } + + /** + * 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 stays active 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; + + // 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; + $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. + */ + 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) { + // 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; + } + + unset($this->tripped[$subscriberId]); + } + + foreach ($this->heldBackFailures as $failures) { + $this->fail($failures); + } + + $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; + } + + /** + * 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 + { + $stopped = $this->subscriptionManager->forEachClaimed( + new SubscriptionCriteria(ids: [$subscriberId], status: [Status::Active, Status::Booting], partitions: false), + function (Subscription $main) use ($throwable): bool { + if ($this->canRetry($main)) { + $main->error($throwable); + } else { + $main->failed($throwable); + } + + $this->subscriptionManager->update($main); + + return true; + }, + wait: true, + ); + + if ($stopped === [] && !$this->isStopped($subscriberId)) { + $this->logger?->warning(sprintf( + 'Subscription Engine: The main subscription of subscriber "%s" could not be stopped, it is not running.', + $subscriberId, + )); + + return false; + } + + $partitionIds = array_keys($this->failedPartitions[$subscriberId] ?? []); + + if ($partitionIds === []) { + return true; + } + + // 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); + }, + wait: true, + ); + + 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::Active, Status::Booting], partitions: true), + function (Subscription $partition) use ($failures): void { + if (!isset($failures[$partition->id()])) { + return; + } + + [$throwable, $message, $index] = $failures[$partition->id()]; + + // 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, + ); + } + + /** 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', + OnBeforeHandleMessage::class => 'onBeforeHandleMessage', + OnHandleMessageSuccess::class => 'onHandleMessageSuccess', + 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/src/Subscription/Engine/Listener/RemovePartitionsListener.php b/src/Subscription/Engine/Listener/RemovePartitionsListener.php new file mode 100644 index 000000000..898dff0e1 --- /dev/null +++ b/src/Subscription/Engine/Listener/RemovePartitionsListener.php @@ -0,0 +1,69 @@ +subscription; + + if ($main->isPartition()) { + return; + } + + $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; + } + + 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/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/Engine/Listener/RetrySubscriber.php b/src/Subscription/Engine/Listener/RetrySubscriber.php index eda2132a4..faeab557d 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); @@ -102,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 817617bcf..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; @@ -14,7 +15,9 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\ArgumentResolverContext; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\EventArgumentResolver; use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\MessageArgumentResolver; +use Patchlevel\EventSourcing\Subscription\Subscriber\ArgumentResolver\PartitionKeyArgumentResolver; 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; @@ -48,6 +51,7 @@ public function __construct( // the check for array is required before PHP 8.2 array_values(is_array($argumentResolvers) ? $argumentResolvers : iterator_to_array($argumentResolvers)), [ + new PartitionKeyArgumentResolver(), new MessageArgumentResolver(), new EventArgumentResolver(), new RecordedOnArgumentResolver(), @@ -55,8 +59,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|ProcessingStopped|null { $subscriber = $this->subscriberRepository->get($subscription->subscriberId()); if (!$subscriber) { @@ -75,56 +87,45 @@ public function process(int $index, Message $message, Subscription $subscription ), ); - $event = new OnHandleMessageSuccess($subscription, $message, $index); - $this->eventDispatcher->dispatch($event); + return $this->success($index, $message, $subscription, $subscriber); + } - if ($event->shouldChangePosition) { - $subscription->changePosition($index); + if (!$subscription->isPartition() && $subscriber->metadata()->partition !== null) { + if ($router === null) { + throw new UnexpectedError(sprintf('The partitioned subscription "%s" needs a router.', $subscription->id())); } - return null; + try { + $router->route($index, $message, $subscription, $subscriber); + } catch (Throwable $e) { + return $this->error($index, $message, $subscription, $subscriber, $e); + } + + return $this->success($index, $message, $subscription, $subscriber); } 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); - } catch (Throwable $e) { - $this->logger?->error( - sprintf( - 'Subscription Engine: Subscriber "%s" for "%s" could not process the event "%s": %s', - $subscriber::class, + if ($before->stop) { + $this->logger?->debug(sprintf( + 'Subscription Engine: Subscription "%s" stopped before the event "%s".', $subscription->id(), $message->event()::class, - $e->getMessage(), - ), - ); + )); - $this->eventDispatcher->dispatch( - new OnHandleMessageError( - $subscription, - $e, - $message, - $index, - ), - ); + return new ProcessingStopped(); + } - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); - } + $this->eventDispatcher->dispatch(new OnHandleMessage($subscription, $message)); - 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 +135,88 @@ 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, - ), - ); + $error = $this->success($index, $message, $subscription, $subscriber); - return new Error( - $subscription->id(), - $e->getMessage(), - $e, - ); + if ($error !== null) { + return $error; } - $event = new OnHandleMessageSuccess( - $subscription, - $message, - $index, + $this->logger?->debug( + sprintf( + 'Subscription Engine: Subscriber "%s" for "%s" processed the event "%s".', + $subscriber::class, + $subscription->id(), + $message->event()::class, + ), ); - $this->eventDispatcher->dispatch($event); + return null; + } + + /** + * 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); - if ($event->shouldChangePosition) { - $subscription->changePosition($index); + try { + $this->eventDispatcher->dispatch($event); + } catch (Throwable $e) { + return $this->error($index, $message, $subscription, $subscriber, $e); } - $this->logger?->debug( + if (!$event->shouldChangePosition) { + return null; + } + + $subscription->changePosition($index); + + return null; + } + + /** @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 +226,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 +250,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 +265,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..45e877689 --- /dev/null +++ b/src/Subscription/Engine/PartitionInbox.php @@ -0,0 +1,57 @@ + */ + private readonly array $lookup; + + /** @param list $indices in ascending order */ + public function __construct( + private readonly array $indices, + ) { + $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]); + } + + 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..79740065e --- /dev/null +++ b/src/Subscription/Engine/PartitionRouter.php @@ -0,0 +1,121 @@ + partition ids known to exist */ + private array $knownPartitions = []; + + /** @var array partition id => main subscription, key and first routed index */ + private array $unknownPartitions = []; + + 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); + + // 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])) { + return; + } + + $this->unknownPartitions[$partitionId] = [$main, $partitionKey, $index]; + } + + /** + * 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 = []; + } + + /** @param MetadataSubscriberAccessor $subscriber */ + private function partitionKey(Message $message, Subscription $main, MetadataSubscriberAccessor $subscriber): string + { + $partitioner = $subscriber->partitioner(); + + if ($partitioner === null) { + throw new UnexpectedError(sprintf('The subscriber of "%s" has no partitioner.', $main->id())); + } + + $partitionKey = $partitioner($message); + + 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; + } +} 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 @@ +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/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/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/src/Subscription/Engine/SubscriptionManager.php b/src/Subscription/Engine/SubscriptionManager.php index d78b6b714..6cb6a2b05 100644 --- a/src/Subscription/Engine/SubscriptionManager.php +++ b/src/Subscription/Engine/SubscriptionManager.php @@ -5,7 +5,10 @@ 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; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\TransactionCommitNotPossible; use Patchlevel\EventSourcing\Subscription\Subscription; @@ -13,7 +16,13 @@ use SplObjectStorage; use Throwable; +use function array_filter; +use function array_map; +use function array_values; +use function count; +use function implode; use function sprintf; +use function usort; /** @internal */ final class SubscriptionManager @@ -27,6 +36,9 @@ final class SubscriptionManager /** @var SplObjectStorage */ private SplObjectStorage $forRemove; + /** @var array> */ + private array $forInbox = []; + public function __construct( private readonly SubscriptionStore $subscriptionStore, private readonly LoggerInterface|null $logger = null, @@ -37,47 +49,142 @@ 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 + * @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 * * @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, + bool $wait = false, + ): array { $snapshot = $this->subscriptionStore->find($criteria); + if ($filter !== null) { + $snapshot = array_values(array_filter($snapshot, $filter)); + } + + $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->claimEach( + $this->groups($snapshot, new ClaimGroups()), + $criteria, + static function (array $subscriptions) use ($closure): mixed { + /** @var non-empty-list $subscriptions */ + 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, 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 + * + * @template T + */ + public function forEachClaimedGroup( + SubscriptionCriteria $criteria, + 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($snapshot, $groups), + $criteria, + $closure, + $onError, + $wait, + ); + } + + /** + * @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, + bool $wait, + ): array { $results = []; - 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, $wait, &$skipped): mixed { + $subscriptions = $this->claim($candidates, $criteria, $wait); - 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) { + // 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(), + $e->getMessage(), + )); + + $subscription->error($e); + $this->update($subscription); + } if ($onError === null) { $skipped = true; @@ -85,7 +192,7 @@ function () use ($candidate, $criteria, $closure, $onError, &$skipped): mixed { return null; } - return $onError($subscription, $e); + return $onError($subscriptions, $e); } finally { $this->flush(); } @@ -96,7 +203,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(), )); @@ -115,12 +222,127 @@ function () use ($candidate, $criteria, $closure, $onError, &$skipped): mixed { return $results; } + /** + * @param non-empty-list $candidates + * + * @return list + */ + 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, + ); + } + + /** + * 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 + * + * @return list> + */ + private function groups(array $subscriptions, ClaimGroups $claimGroups): array + { + $partitionsPerClaim = $claimGroups->partitions; + $subscriptionsPerClaim = $claimGroups->subscriptions; + $positionWindow = $claimGroups->positionWindow; + + $groups = []; + $partitions = []; + $others = []; + + foreach ($subscriptions as $subscription) { + if (!$subscription->isPartition()) { + if ($subscriptionsPerClaim === 1) { + $groups[] = [$subscription]; + } else { + $others[] = $subscription; + } + + continue; + } + + $partitions[] = $subscription; + + if (count($partitions) < $partitionsPerClaim) { + continue; + } + + $groups[] = $partitions; + $partitions = []; + } + + if ($partitions !== []) { + $groups[] = $partitions; + } + + 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 === []) { + $lowest = $position; + } + + $group[] = $subscription; + } + + if ($group !== []) { + $groups[] = $group; + } + + return $groups; + } + /** @return list */ public function find(SubscriptionCriteria $criteria): array { return $this->subscriptionStore->find($criteria); } + 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) { @@ -142,16 +364,65 @@ 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; + } + + /** + * @param array $afterIndex subscription id => position of the partition + * @param positive-int $limit + * + * @return array> + */ + public function inbox(array $afterIndex, int $limit): array + { + if ($afterIndex === []) { + return []; + } + + 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; + } + + private function inboxStore(): PartitionInboxStore + { + if (!$this->subscriptionStore instanceof PartitionInboxStore) { + throw new PartitionsNotSupported($this->subscriptionStore::class); + } + + return $this->subscriptionStore; + } + 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; @@ -161,15 +432,34 @@ public function flush(): void continue; } - $this->subscriptionStore->update($subscription); + $toUpdate[] = $subscription; } + $toRemove = []; + foreach ($this->forRemove as $subscription) { if ($this->forAdd->offsetExists($subscription)) { continue; } - $this->subscriptionStore->remove($subscription); + $toRemove[] = $subscription; + } + + // 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 ($toRemove !== []) { + $this->subscriptionStore->remove(...$toRemove); + } + + if ($this->forInbox !== []) { + $this->inboxStore()->addToInbox($this->forInbox); } $this->clearPending(); @@ -180,5 +470,6 @@ private function clearPending(): void $this->forAdd = new SplObjectStorage(); $this->forUpdate = new SplObjectStorage(); $this->forRemove = new SplObjectStorage(); + $this->forInbox = []; } } diff --git a/src/Subscription/Engine/SubscriptionRunner.php b/src/Subscription/Engine/SubscriptionRunner.php index 4c95da82e..0b282adf8 100644 --- a/src/Subscription/Engine/SubscriptionRunner.php +++ b/src/Subscription/Engine/SubscriptionRunner.php @@ -4,21 +4,42 @@ 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; 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; 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; +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; + + /** 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; + public function __construct( private readonly MessageLoader $messageLoader, private readonly SubscriptionManager $subscriptionManager, @@ -47,89 +68,532 @@ public function process(array $subscriptions, int|null $limit, bool $boot = fals $surviving[] = $subscription; } + // 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); + + if ($separated !== null) { + return $separated; + } + } + $members = new SubscriptionCollection($surviving); if (count($members) === 0) { 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); + $this->removeIdlePartitions($working); /** @var list $errors */ $errors = []; $stream = null; $messageCounter = 0; $lastIndex = null; + /** @var array $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; - try { - $stream = $this->messageLoader->load($startIndex, $members->toArray()); + 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 { + $stream = $indices !== null ? $this->loadIndices($indices) : null; + + if ($stream === null) { + $this->logger?->debug(sprintf( + 'Subscription Engine: Event stream is processed from position %s.', + $startIndex ?? 'beginning', + )); - foreach ($stream as $index => $message) { - $messageCounter++; - $lastIndex = $index; + $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; + $loaded[$index] = true; + + foreach ($working as $member) { + if ($member->position() >= $index) { + continue; + } + + if ($member->isPartition() && !$inboxes[$member->id()]->contains($index)) { + continue; + } + + $error = $this->messageProcessor->process($index, $message, $member, $router); + + if ($error === null) { + $lastHandled[$member->id()] = $index; + + continue; + } - if (!$error) { - continue; + $working->remove($member); + $members->remove($member); + + if ($error instanceof ProcessingStopped) { + $stopped[] = $member; + } else { + $errors[] = $error; + } + + if (count($working) === 0) { + break 2; + } } - $errors[] = $error; - $members->remove($member); + // the inbox of every partition is already limited, a shared limit would starve the others + if (!$onlyPartitions && $limit !== null && $messageCounter >= $limit) { + $this->logger?->info(sprintf( + 'Subscription Engine: Message limit (%d) reached, finish processing.', + $limit, + )); + + $limitReached = true; - if (count($members) === 0) { - break 2; + break; } } + } finally { + $stream?->close(); + // the routed messages are flushed in any case, so their partitions have to exist as well + $router->createPartitions(); + } + } - if ($limit !== null && $messageCounter >= $limit) { - $this->logger?->info(sprintf( - 'Subscription Engine: Message limit (%d) reached, finish processing.', - $limit, - )); + if ($lastIndex !== null) { + foreach ($working as $member) { + $errors = array_merge($errors, $this->processed($member, $lastIndex)); + } + } - $limitReached = true; + // 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; + } - break; - } + $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); + } elseif ($lastIndex === null) { + continue; + } + + $this->subscriptionManager->update($member); + } + + $inboxFull = $this->hasFullInbox($members, $inboxes, $inboxLimit); + + if (!$limitReached && !$inboxFull) { + $this->finishMembers($members, $mains, $boot); + } + + return new ProcessedResult( + $messageCounter, + !$limitReached && !$inboxFull, + $errors, + ); + } + + /** + * 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 + { + $event = new OnSubscriptionProcessed($subscription, $lastIndex); + $this->eventDispatcher->dispatch($event); + + return $event->errors; + } + + /** + * @param list $subscriptions + * + * @return array subscriber id => main subscription + */ + private function mains(array $subscriptions): array + { + $subscriberIds = []; + + foreach ($subscriptions as $subscription) { + if (!$subscription->isPartition()) { + continue; + } + + $subscriberIds[$subscription->subscriberId()] = true; + } + + if ($subscriberIds === []) { + return []; + } + + $mains = []; + + foreach ( + $this->subscriptionManager->find( + new SubscriptionCriteria(ids: array_keys($subscriberIds), partitions: false), + ) as $main + ) { + $mains[$main->id()] = $main; + } + + 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) { + $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(), + )); + + 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 + * 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 + * + * @return array partition id => its inbox + */ + private function loadInboxes(SubscriptionCollection $members, int $limit): array + { + $afterIndex = []; + + foreach ($members as $member) { + if (!$member->isPartition()) { + continue; + } + + $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; + } + + /** @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]; + } + + /** + * 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))); + + // 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 { + $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(); + $stream->close(); } + } - if ($lastIndex !== null) { - foreach ($members as $member) { - $event = new OnSubscriptionProcessed($member, $lastIndex); - $this->eventDispatcher->dispatch($event); - $errors = array_merge($errors, $event->errors); + /** + * 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 ($lastIndex !== null) { - foreach ($members as $member) { - $this->subscriptionManager->update($member); + 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. + * 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, array $loaded): void + { + $position = $partition->position() ?? -1; + + if ($position >= $inbox->last()) { + $partition->changePosition($inbox->last()); + + return; + } + + 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; } } - if (!$limitReached) { - $this->finishMembers($members, $boot); + $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()); + } + + /** @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 new ProcessedResult($messageCounter, !$limitReached, $errors); + 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) { @@ -137,7 +601,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() && ($mains[$member->subscriberId()] ?? null)?->isBooting()) { + continue; + } + + if ($member->runMode() === RunMode::Once && $this->isRoutingFinished($member, $mains)) { $member->finished(); $this->logger?->info(sprintf( @@ -162,6 +631,10 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi continue; } + if (!$this->isRoutingFinished($member, $mains)) { + continue; + } + $member->finished(); $this->subscriptionManager->update($member); @@ -171,4 +644,18 @@ private function finishMembers(SubscriptionCollection $members, bool $boot): voi )); } } + + /** + * 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 ($mains[$subscription->subscriberId()] ?? null)?->isFinished() ?? true; + } } 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/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 @@ +partitioner)($message)) % $this->buckets); + } +} diff --git a/src/Subscription/Partitioner/HashedPartitioner.php b/src/Subscription/Partitioner/HashedPartitioner.php new file mode 100644 index 000000000..3d0a37275 --- /dev/null +++ b/src/Subscription/Partitioner/HashedPartitioner.php @@ -0,0 +1,27 @@ +algorithm, ($this->partitioner)($message)); + } +} 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/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..fe6d57410 --- /dev/null +++ b/src/Subscription/Partitioner/TagPartitioner.php @@ -0,0 +1,53 @@ +prefix . ':'; + + try { + $tags = $message->header(TagsHeader::class)->tags; + } catch (HeaderNotFound) { + throw TagNotUnique::missing($prefix); + } + + $matching = array_values(array_filter( + $tags, + static fn (string $tag): bool => str_starts_with($tag, $prefix), + )); + + if ($matching === []) { + throw TagNotUnique::missing($prefix); + } + + if (count($matching) > 1) { + throw TagNotUnique::ambiguous($prefix, $matching); + } + + return $matching[0]; + } +} diff --git a/src/Subscription/Store/DoctrineSubscriptionStore.php b/src/Subscription/Store/DoctrineSubscriptionStore.php index 98ae14848..330e20ab1 100644 --- a/src/Subscription/Store/DoctrineSubscriptionStore.php +++ b/src/Subscription/Store/DoctrineSubscriptionStore.php @@ -11,7 +11,10 @@ use Doctrine\DBAL\Exception\DriverException; 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; use Doctrine\DBAL\Query\ForUpdate\ConflictResolutionMode; use Doctrine\DBAL\Query\QueryBuilder; @@ -26,12 +29,23 @@ use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; use Psr\Clock\ClockInterface; +use ReflectionClass; +use function array_chunk; +use function array_fill; +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; +use function in_array; use function json_decode; use function json_encode; use function serialize; +use function sort; +use function sprintf; use function unserialize; use const JSON_THROW_ON_ERROR; @@ -48,15 +62,105 @@ * retry_attempt: int, * last_saved_at: string, * cleanup_tasks: string|null, + * 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, + * 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; + + /** 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; + + /** find() leaves out error_context and cleanup_tasks, see createLazySubscription(). */ + private const LAZY_LOADED_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, + '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( private readonly Connection $connection, private readonly ClockInterface $clock = new SystemClock(), private readonly string $tableName = 'subscriptions', + string|null $inboxTableName = null, ) { + $this->inboxTableName = $inboxTableName ?? $tableName . '_inbox'; } public function get(string $subscriptionId): Subscription @@ -77,60 +181,187 @@ public function get(string $subscriptionId): Subscription return $this->createSubscription($result); } - /** @return list */ + /** + * 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 { + 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 + { + // 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('*') + ->select(...$columns) ->from($this->tableName) - ->orderBy('id'); + ->orderBy($pendingPartitions === true ? $this->tableName . '.id' : 'id'); if ($criteria !== null) { $this->applyCriteria($qb, $criteria); } - /** @var list $result */ + 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(); return array_map( - fn (array $data) => $this->createSubscription($data), + fn (array $data) => $this->createLazySubscription($data), $result, ); } - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null + 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); + } + + 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 + * + * @return list + */ + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array { $qb = $this->connection->createQueryBuilder() ->select('*') ->from($this->tableName) - ->where('id = :id') - ->setParameter('id', $id); + ->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); + $qb->forUpdate($wait ? ConflictResolutionMode::ORDINARY : ConflictResolutionMode::SKIP_LOCKED); } - /** @var Data|false $result */ - $result = $qb->fetchAssociative(); + /** @var list $result */ + $result = $qb->fetchAllAssociative(); - if ($result === false) { - return null; + 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; } - return $this->createSubscription($result); + $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) { + // 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', + $criteria->ids, + ArrayParameterType::STRING, + ) + ->setParameter( + 'subscriber_ids', + $criteria->ids, + ArrayParameterType::STRING, + ); } if ($criteria->groups !== null) { @@ -142,40 +373,97 @@ 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) { return; } - $qb->andWhere('status IN (:status)') - ->setParameter( - 'status', - array_map(static fn (Status $status) => $status->value, $criteria->status), - ArrayParameterType::STRING, - ); + $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 { - $subscriptionError = $subscription->subscriptionError(); + $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 + { + $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], + ); + } + } + + private function insert(Subscription $subscription): void + { $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, - ], + $this->row($subscription), [ 'last_saved_at' => Types::DATETIME_IMMUTABLE, ], @@ -185,26 +473,58 @@ public function add(Subscription $subscription): void } } - public function update(Subscription $subscription): void + /** @param non-empty-list $subscriptions */ + private function insertAll(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, + ); + } + } + } + private function updateOne(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(), ], @@ -233,9 +553,375 @@ public function update(Subscription $subscription): void } } - public function remove(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 + */ + private function updateAll(array $subscriptions): void { - $this->connection->delete($this->tableName, ['id' => $subscription->id()]); + $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(), + ]; + } + + /** @param array> $indices */ + public function addToInbox(array $indices): void + { + $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 array $afterIndex + * @param positive-int $limit + * + * @return array> + */ + public function inbox(array $afterIndex, int $limit): array + { + $inbox = []; + $isPostgres = $this->connection->getDatabasePlatform() instanceof PostgreSQLPlatform; + + foreach (array_chunk($afterIndex, self::INBOX_UNION_CHUNK, true) as $chunk) { + $parameters = []; + $types = []; + + foreach ($chunk as $subscriptionId => $index) { + $parameters[] = (string)$subscriptionId; + $parameters[] = $index; + $types[] = ParameterType::STRING; + $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); + + /** @var list $rows */ + $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 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 + { + 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; + } + + // 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, + ); } /** @@ -276,7 +962,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); @@ -302,9 +989,48 @@ 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) + ->setPlatformOptions($this->caseSensitive($connection)); + $table->addColumn('partition_key', Types::STRING) + ->setLength(255) + ->setNotnull(false) + ->setPlatformOptions($this->caseSensitive($connection)); + $table->setPrimaryKey(['id']); $table->addIndex(['group_name']); $table->addIndex(['status']); + // finds the main subscriptions without reading the partitions + $table->addIndex(['partition_key', 'status']); + // 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); + + $inbox->addColumn('subscription_id', Types::STRING) + ->setLength(255) + ->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 */ @@ -327,9 +1053,103 @@ 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'], ); } + /** + * 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 + */ + private function createLazySubscription(array $row): Subscription + { + $details = $this->detailsLoader($row['id']); + + $error = null; + + if ($row['error_message'] !== null) { + $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); + } + } + + $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'], + ]; + + 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); + } + + 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 b6f44b311..9669ab178 100644 --- a/src/Subscription/Store/InMemorySubscriptionStore.php +++ b/src/Subscription/Store/InMemorySubscriptionStore.php @@ -11,14 +11,24 @@ use function array_filter; use function array_key_exists; +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; -final class InMemorySubscriptionStore implements SubscriptionStore +use const PHP_INT_MAX; + +final class InMemorySubscriptionStore implements PartitionInboxStore { /** @var array */ private array $subscriptions = []; + /** @var array> */ + private array $inbox = []; + /** @param list $subscriptions */ public function __construct( array $subscriptions = [], @@ -50,24 +60,46 @@ 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), ), ); } - public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null + public function stats(SubscriptionCriteria $criteria): SubscriptionStats { - if (!array_key_exists($id, $this->subscriptions)) { - return null; + $status = []; + + foreach ($this->find($criteria) as $subscription) { + $status[$subscription->status()->value] = ($status[$subscription->status()->value] ?? 0) + 1; } - $subscription = $this->subscriptions[$id]; + return new SubscriptionStats($status); + } + + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array + { + $claimed = []; + + foreach ($ids as $id) { + if (!array_key_exists($id, $this->subscriptions)) { + continue; + } + + $subscription = $this->subscriptions[$id]; + + if (!$this->matches($subscription, $criteria)) { + continue; + } - if (!self::matches($subscription, $criteria)) { - return null; + $claimed[] = $subscription; } - return $subscription; + return $claimed; } /** @@ -82,10 +114,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,38 +137,157 @@ 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; } - 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()); + foreach ($subscriptions as $subscription) { + if (array_key_exists($subscription->id(), $this->subscriptions)) { + throw new SubscriptionAlreadyExists($subscription->id()); + } + + $subscription->updateLastSavedAt($this->clock->now()); + + $this->subscriptions[$subscription->id()] = $subscription; } + } + + /** The inbox of an updated partition is cleared up to its position. */ + public function update(Subscription ...$subscriptions): void + { + foreach ($subscriptions as $subscription) { + if (!array_key_exists($subscription->id(), $this->subscriptions)) { + throw new SubscriptionNotFound($subscription->id()); + } + + $subscription->updateLastSavedAt($this->clock->now()); - $subscription->updateLastSavedAt($this->clock->now()); + $this->subscriptions[$subscription->id()] = $subscription; - $this->subscriptions[$subscription->id()] = $subscription; + $position = $subscription->position(); + + if (!$subscription->isPartition() || $position === null) { + continue; + } + + foreach (array_keys($this->inbox[$subscription->id()] ?? []) as $messageIndex) { + if ($messageIndex > $position) { + continue; + } + + unset($this->inbox[$subscription->id()][$messageIndex]); + } + } } - public function update(Subscription $subscription): void + /** The inbox of a removed partition is removed as well. */ + public function remove(Subscription ...$subscriptions): void { - if (!array_key_exists($subscription->id(), $this->subscriptions)) { - throw new SubscriptionNotFound($subscription->id()); + foreach ($subscriptions as $subscription) { + unset($this->subscriptions[$subscription->id()], $this->inbox[$subscription->id()]); } + } + + /** @param array> $indices */ + public function addToInbox(array $indices): void + { + foreach ($indices as $subscriptionId => $messageIndices) { + foreach ($messageIndices as $messageIndex) { + $this->inbox[$subscriptionId][$messageIndex] = true; + } + } + } + + /** + * @param array $afterIndex + * @param positive-int $limit + * + * @return array> + */ + public function inbox(array $afterIndex, int $limit): array + { + $inbox = []; - $subscription->updateLastSavedAt($this->clock->now()); + 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; + } - $this->subscriptions[$subscription->id()] = $subscription; + sort($indices); + + $inbox[$subscriptionId] = array_slice($indices, 0, $limit); + } + + return $inbox; } - public function remove(Subscription $subscription): void + public function inboxStats(SubscriptionCriteria $criteria): InboxStats { - unset($this->subscriptions[$subscription->id()]); + $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 + */ + 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 = []; + $this->inbox = []; } } 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 @@ +> $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; + + /** Counts the messages waiting in the inboxes of the matching partitions, without loading them. */ + public function inboxStats(SubscriptionCriteria $criteria): InboxStats; + + /** + * 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/src/Subscription/Store/SubscriptionCriteria.php b/src/Subscription/Store/SubscriptionCriteria.php index 22d176af8..5aed924da 100644 --- a/src/Subscription/Store/SubscriptionCriteria.php +++ b/src/Subscription/Store/SubscriptionCriteria.php @@ -10,14 +10,20 @@ 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, + * only used with a PartitionInboxStore */ 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/SubscriptionStats.php b/src/Subscription/Store/SubscriptionStats.php new file mode 100644 index 000000000..bec06396b --- /dev/null +++ b/src/Subscription/Store/SubscriptionStats.php @@ -0,0 +1,31 @@ +, 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, + ) { + } + + 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 e68014c8e..528621862 100644 --- a/src/Subscription/Store/SubscriptionStore.php +++ b/src/Subscription/Store/SubscriptionStore.php @@ -12,20 +12,34 @@ interface SubscriptionStore /** @throws SubscriptionNotFound */ public function get(string $subscriptionId): Subscription; - /** @return list */ + /** + * A store may load the error context and the cleanup tasks of the found subscriptions only when they are accessed. + * + * @return list + */ 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; + /** Counts the matching subscriptions per status, without loading them. */ + public function stats(SubscriptionCriteria $criteria): SubscriptionStats; + + /** + * 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, bool $wait = false): array; /** @throws SubscriptionAlreadyExists */ - public function add(Subscription $subscription): void; + public function add(Subscription ...$subscriptions): void; /** @throws SubscriptionNotFound */ - public function update(Subscription $subscription): void; + public function update(Subscription ...$subscriptions): void; - /** @throws SubscriptionNotFound */ - public function remove(Subscription $subscription): void; + public function remove(Subscription ...$subscriptions): void; /** * @param Closure():T $closure 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/src/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolver.php b/src/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolver.php new file mode 100644 index 000000000..14701e38b --- /dev/null +++ b/src/Subscription/Subscriber/ArgumentResolver/PartitionKeyArgumentResolver.php @@ -0,0 +1,22 @@ +subscription->partitionKey(); + } + + public function support(ArgumentMetadata $argument, string $eventClass): bool + { + return $argument->hasAttribute(PartitionKey::class); + } +} diff --git a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php index 2a0d18bc7..cab068c7b 100644 --- a/src/Subscription/Subscriber/MetadataSubscriberAccessor.php +++ b/src/Subscription/Subscriber/MetadataSubscriberAccessor.php @@ -9,6 +9,7 @@ use Patchlevel\EventSourcing\Message\Message; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscribeMethodMetadata; use Patchlevel\EventSourcing\Metadata\Subscriber\SubscriberMetadata; +use Patchlevel\EventSourcing\Subscription\Partitioner\Partitioner; use Throwable; use function array_key_exists; @@ -84,6 +85,11 @@ public function failedMethod(): Closure|null return $this->subscriber->$method(...); } + public function partitioner(): Partitioner|null + { + return $this->metadata->partition?->partitioner; + } + /** @return list */ public function events(): array { diff --git a/src/Subscription/Subscription.php b/src/Subscription/Subscription.php index 7767518c7..239f2bd51 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,43 @@ 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. 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 + { + if ($main->isPartition()) { + 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(), + $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 +73,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..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 @@ -145,6 +151,7 @@ public function testMetadataSubscriberCanOnlyDependOnAllowedLayers(): Rule $this->layer('Metadata\Subscriber'), [ $this->layer('Attribute'), + $this->layer('Message'), $this->metadataLayer(), $this->layer('Subscription'), ], 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..430f8ad3f --- /dev/null +++ b/tests/Benchmark/BasicImplementation/Projection/PartitionedCountingProjector.php @@ -0,0 +1,32 @@ +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/Integration/Subscription/ConcurrentWorkerTest.php b/tests/Integration/Subscription/ConcurrentWorkerTest.php new file mode 100644 index 000000000..66a885305 --- /dev/null +++ b/tests/Integration/Subscription/ConcurrentWorkerTest.php @@ -0,0 +1,301 @@ +}> */ + 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(); + } + + /** @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); + + (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([ + match ($projector) { + 'plain' => new ConcurrentProfileProjection($this->connection, 'test'), + 'batch' => new ConcurrentBatchProfileProjection($this->connection, 'test'), + 'emitting' => new ConcurrentEmittingProfileProjection($this->connection, 'test'), + }, + ]); + + (new DefaultSubscriptionEngine( + new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository), + $subscriptionStore, + $subscriberRepository, + ))->execute(new Setup(skipBooting: true)); + + $this->startWorker('worker-a', $projector); + $this->startWorker('worker-b', $projector); + + /** @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: [$subscriberId])); + 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'); + + 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, string $projector): void + { + $process = proc_open( + [PHP_BINARY, __DIR__ . '/../Worker/partition-worker.php', $name, $this->stopFile, $projector], + [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/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/DoctrineSubscriptionStoreTest.php b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php index 623aebf51..ef1aa4b03 100644 --- a/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php +++ b/tests/Integration/Subscription/DoctrineSubscriptionStoreTest.php @@ -17,14 +17,17 @@ 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; 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; @@ -1027,6 +1030,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]); } @@ -1090,4 +1095,420 @@ 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(); + + $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)); + 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'); + + $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), + 'updating a partition clears its inbox up to its position', + ); + } + + 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(); + + $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], 'profile#processed' => [7]]); + + self::assertSame( + ['profile', 'profile#pending'], + array_map( + static fn (Subscription $s) => $s->id(), + $store->find(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 + { + $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; + } + + 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 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 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(); + + $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->claim(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), + ), + ); + } + + 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->add(...[$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->add(...[new Subscription('bar'), new Subscription('foo')]); + } + + public function testUpdateAll(): void + { + $store = $this->createStore(); + + $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), + ]); + + $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->update(...[$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->add(...[new Subscription('a'), new Subscription('b')]); + + // mysql and mariadb report no affected rows if nothing changed + $store->update(...[$store->get('a'), $store->get('b')]); + $store->update(...[$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->update(...[$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->add(...$subscriptions); + + foreach ($subscriptions as $i => $subscription) { + $subscription->changePosition($i + 1); + } + + $store->update(...$subscriptions); + + self::assertCount(250, $store->find()); + self::assertSame(1, $store->get('s0')->position()); + self::assertSame(250, $store->get('s249')->position()); + } + + public function testFindLoadsErrorContextAndCleanupTasksOnAccess(): 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->find(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->find(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', cleanupTasks: [new DropTableTask('baz')])); + + [$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/GroupedClaimTest.php b/tests/Integration/Subscription/GroupedClaimTest.php new file mode 100644 index 000000000..3ca11b233 --- /dev/null +++ b/tests/Integration/Subscription/GroupedClaimTest.php @@ -0,0 +1,381 @@ +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 SetupCommand(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()); + } + + 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()); + } + + 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/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 new file mode 100644 index 000000000..10c02c5a2 --- /dev/null +++ b/tests/Integration/Subscription/PartitionedSubscriptionTest.php @@ -0,0 +1,1099 @@ + */ + private Repository $repository; + + 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(); + } + + 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::assertTrue($result->finished, 'the partitions process the routed messages in the same run'); + + $partitions = $this->partitions(); + self::assertCount(3, $partitions); + + 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::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'); + $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'); + } + } + + 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); + ConfigurablePartitioner::$key = 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 + ConfigurablePartitioner::$key = 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(); + ConfigurablePartitioner::$key = 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(); + ConfigurablePartitioner::$key = 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()); + } + } + + /** 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); + + // 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'); + $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]); + 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', + ); + } + + /** 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); + + 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 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()), + ); + + 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. */ + 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()]); + } + + /** @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(); + ConfigurablePartitioner::$key = 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); + } + + 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; + } + + /** 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()); + } + } + + /** 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]); + } + } + + /** 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]); + } + } + + /** 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, + string $messageLoader = 'index', + bool $inboxStore = true, + EventDispatcher $eventDispatcher = new EventDispatcher(), + ): SubscriptionEngine { + $store = $this->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]); + + $loader = new EventFilteredStoreMessageLoader($store, new AttributeEventMetadataFactory(), $subscriberRepository); + + return new DefaultSubscriptionEngine( + 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), + ]), + eventDispatcher: $eventDispatcher, + ); + } + + /** + * @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 + * @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, partitionsPerClaim: $partitionsPerClaim)); + 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; + } + + 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'); + } + } + + /** @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( + [$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)); + } + + /** @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()); + } + + /** 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, eventDispatcher: $eventDispatcher); + + $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'); + self::assertSame(0, $open, 'every OnHandleMessage got its success or error, also for the stopped partitions'); + + // 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/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..9394a97f6 --- /dev/null +++ b/tests/Integration/Subscription/PlainSubscriptionStore.php @@ -0,0 +1,73 @@ +store->get($subscriptionId); + } + + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + return $this->store->stats($criteria); + } + + /** @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, bool $wait = false): array + { + return $this->store->claim($ids, $criteria, $wait); + } + + 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/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php new file mode 100644 index 000000000..522f1185b --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/ConcurrentBatchProfileProjection.php @@ -0,0 +1,69 @@ +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..b60835e3d --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/ConcurrentEmittingProfileProjection.php @@ -0,0 +1,53 @@ +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/Subscription/Subscriber/ConcurrentProfileProjection.php b/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php new file mode 100644 index 000000000..0015929b3 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/ConcurrentProfileProjection.php @@ -0,0 +1,49 @@ +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/Subscription/Subscriber/EphemeralPartitionProjection.php b/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php new file mode 100644 index 000000000..beceffc09 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/EphemeralPartitionProjection.php @@ -0,0 +1,50 @@ +> profile id => processed names */ + public array $processed = []; + + /** @var list */ + public array $failingNames = []; + + #[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/Integration/Subscription/Subscriber/OncePartitionedProjection.php b/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php new file mode 100644 index 000000000..1ef49afdd --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/OncePartitionedProjection.php @@ -0,0 +1,27 @@ +handled++; + } +} diff --git a/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php new file mode 100644 index 000000000..bb0cfd879 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/PartitionedBatchProfileProjection.php @@ -0,0 +1,75 @@ +> profile id => flushed names */ + public array $flushed = []; + + 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] + 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 [, $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/Integration/Subscription/Subscriber/PartitionedProfileProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php new file mode 100644 index 000000000..158ddf117 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/PartitionedProfileProjection.php @@ -0,0 +1,60 @@ +> profile id => processed names in processing order */ + public array $processed = []; + + 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 = []; + + #[Subscribe(ProfileCreated::class)] + public function handleProfileCreated(ProfileCreated $event, #[PartitionKey] + string $partitionKey,): void + { + $this->partitionKeys[$event->profileId->toString()] = $partitionKey; + $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/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php b/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php new file mode 100644 index 000000000..f8a62ec69 --- /dev/null +++ b/tests/Integration/Subscription/Subscriber/PartitionedTransactionalBatchProjection.php @@ -0,0 +1,87 @@ +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(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(); + } +} 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/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/Integration/Worker/partition-worker.php b/tests/Integration/Worker/partition-worker.php new file mode 100644 index 000000000..ba712a16d --- /dev/null +++ b/tests/Integration/Worker/partition-worker.php @@ -0,0 +1,83 @@ +parse($dbUrl), + (new Configuration())->setMiddlewares([new PostgreSQLPlatformMiddleware()]), +); + +$store = new StreamDoctrineDbalStore($connection, DefaultEventSerializer::createFromPaths([__DIR__ . '/../Subscription/Events'])); +$subscriberRepository = new MetadataSubscriberAccessorRepository([ + 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; +$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); diff --git a/tests/Unit/Attribute/SubscriberTest.php b/tests/Unit/Attribute/SubscriberTest.php index 2ef4d0770..06444cdb2 100644 --- a/tests/Unit/Attribute/SubscriberTest.php +++ b/tests/Unit/Attribute/SubscriberTest.php @@ -4,12 +4,14 @@ namespace Patchlevel\EventSourcing\Tests\Unit\Attribute; +use Patchlevel\EventSourcing\Attribute\InvalidSubscriberId; use Patchlevel\EventSourcing\Attribute\Subscriber; use Patchlevel\EventSourcing\Subscription\RunMode; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(Subscriber::class)] +#[CoversClass(InvalidSubscriberId::class)] final class SubscriberTest extends TestCase { public function testInstantiate(): void @@ -18,4 +20,11 @@ public function testInstantiate(): void self::assertSame('foo', $attribute->id); } + + public function testIdWithPartitionSeparator(): void + { + $this->expectException(InvalidSubscriberId::class); + + new Subscriber('foo#bar', RunMode::FromBeginning); + } } diff --git a/tests/Unit/Console/Command/FakeSubscriptionEngine.php b/tests/Unit/Console/Command/FakeSubscriptionEngine.php new file mode 100644 index 000000000..0792c48ef --- /dev/null +++ b/tests/Unit/Console/Command/FakeSubscriptionEngine.php @@ -0,0 +1,77 @@ + */ + 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 $this->filter($criteria); + } + + /** @var list */ + public array $statsCriteria = []; + + public InboxStats|null $inbox = null; + + 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, $this->inbox); + } + + /** @return list */ + private function filter(SubscriptionEngineCriteria $criteria): array + { + 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..ec48d7e19 100644 --- a/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php +++ b/tests/Unit/Console/Command/SubscriptionStatusCommandTest.php @@ -5,10 +5,10 @@ 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; +use Patchlevel\EventSourcing\Subscription\Store\InboxStats; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionNotFound; use Patchlevel\EventSourcing\Subscription\Subscription; use Patchlevel\EventSourcing\Subscription\SubscriptionError; @@ -22,15 +22,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 +41,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 +57,70 @@ 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 testPartitionSummaryWithoutLoadingThePartitions(): 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, + 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), inbox: 7 in 2', $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 + { + $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); } 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 +131,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 +178,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); + } } diff --git a/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php b/tests/Unit/Metadata/Subscriber/AttributeSubscriberMetadataFactoryTest.php index 3cbf71c69..e54e6b670 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; @@ -37,7 +38,9 @@ use Patchlevel\EventSourcing\Metadata\Subscriber\DuplicateTeardownMethod; use Patchlevel\EventSourcing\Metadata\Subscriber\IncompleteBatchMethods; 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; @@ -46,6 +49,9 @@ use stdClass; use Symfony\Component\TypeInfo\Type; +use function serialize; +use function unserialize; + #[CoversClass(AttributeSubscriberMetadataFactory::class)] final class AttributeSubscriberMetadataFactoryTest extends TestCase { @@ -284,6 +290,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 +808,43 @@ class { self::assertSame('custom_strategy', $metadata->retryStrategy); } + + public function testPartition(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] + class { + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertEquals(new PartitionMetadata(new StreamPartitioner(), 5, null), $metadata->partition); + } + + public function testPartitionWithOptions(): void + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner(), maxConsecutiveFailures: 3, removeIdleAfter: 60)] + class { + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + 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 + { + $subscriber = new #[Subscriber('foo', RunMode::FromBeginning)] + class { + }; + + $metadataFactory = new AttributeSubscriberMetadataFactory(); + $metadata = $metadataFactory->metadata($subscriber::class); + + self::assertNull($metadata->partition); + } } 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/DummySubscriptionStore.php b/tests/Unit/Subscription/DummySubscriptionStore.php index 491ac0bf6..a56768b3c 100644 --- a/tests/Unit/Subscription/DummySubscriptionStore.php +++ b/tests/Unit/Subscription/DummySubscriptionStore.php @@ -7,14 +7,16 @@ 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; -use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; +use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStats; 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; @@ -29,6 +31,9 @@ final class DummySubscriptionStore implements SubscriptionStore /** @var list */ public array $removedSubscriptions = []; + /** @var list */ + public array $claimedIds = []; + /** @param list $subscriptions */ public function __construct( array $subscriptions = [], @@ -42,15 +47,29 @@ 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 { return $this->parentStore->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, bool $wait = false): array { - return $this->parentStore->claim($id, $criteria); + foreach ($ids as $id) { + $this->claimedIds[] = $id; + } + + return $this->parentStore->claim($ids, $criteria, $wait); } /** @@ -65,22 +84,62 @@ 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); + + foreach ($subscriptions as $subscription) { + $this->addedSubscriptions[] = clone $subscription; + } } - public function update(Subscription $subscription): void + public function update(Subscription ...$subscriptions): void { - $this->parentStore->update($subscription); - $this->updatedSubscriptions[] = clone $subscription; + $this->parentStore->update(...$subscriptions); + + foreach ($subscriptions as $subscription) { + $this->updatedSubscriptions[] = clone $subscription; + } } - public function remove(Subscription $subscription): void + public function remove(Subscription ...$subscriptions): void + { + $this->parentStore->remove(...$subscriptions); + + foreach ($subscriptions as $subscription) { + $this->removedSubscriptions[] = clone $subscription; + } + } + + /** @param array> $indices */ + public function addToInbox(array $indices): void + { + $this->parentStore->addToInbox($indices); + } + + /** + * @param array $afterIndex + * @param positive-int $limit + * + * @return array> + */ + 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 + */ + public function removeIdlePartitions(string $subscriberId, int $idleSeconds, int $limit): int { - $this->parentStore->remove($subscription); - $this->removedSubscriptions[] = clone $subscription; + return $this->parentStore->removeIdlePartitions($subscriberId, $idleSeconds, $limit); } public function reset(): void 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/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/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/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()); + } + } } 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/PartitionCircuitBreakerSubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php new file mode 100644 index 000000000..d77131d8c --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/PartitionCircuitBreakerSubscriberTest.php @@ -0,0 +1,354 @@ + */ + 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])->stop, 'not tripped yet'); + + $breaker->onHandleMessageError($this->error($this->partitions[2])); + + 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 + { + $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])->stop); + } + + 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 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::Active, $this->partitions[$i]->status(), 'nothing about the failure is stored yet'); + } + + $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()); + } + + /** 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()); + } + + /** 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); + $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): PartitionCircuitBreakerSubscriber + { + $subscriber = $maxConsecutiveFailures === 1 + ? new #[Subscriber('profile', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner(), maxConsecutiveFailures: 1)] + class { + #[Subscribe(ProfileVisited::class)] + public function on(ProfileVisited $event): void + { + } + } + : new #[Subscriber('profile', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner(), maxConsecutiveFailures: 3)] + class { + #[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]); + + $subscriptionManager = new SubscriptionManager($this->store); + $subscriberRepository = new MetadataSubscriberAccessorRepository([$subscriber]); + + return new PartitionCircuitBreakerSubscriber( + $subscriptionManager, + $subscriberRepository, + RetryStrategyRepository::withDefault(new NoRetryStrategy()), + new FailedMessageProcessor($subscriptionManager, $subscriberRepository), + ); + } + + private function error(Subscription $partition): OnHandleMessageError + { + return new OnHandleMessageError($partition, new RuntimeException('database down'), $this->message(), 1); + } + + private function handle(PartitionCircuitBreakerSubscriber $breaker, Subscription $partition): OnBeforeHandleMessage + { + $event = new OnBeforeHandleMessage($partition, $this->message()); + $breaker->onBeforeHandleMessage($event); + + return $event; + } + + private function message(): Message + { + return Message::create(new ProfileVisited(ProfileId::fromString('1'))); + } +} diff --git a/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php new file mode 100644 index 000000000..78087acc5 --- /dev/null +++ b/tests/Unit/Subscription/Engine/Listener/RemovePartitionsListenerTest.php @@ -0,0 +1,144 @@ +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)); + } + + public function stats(SubscriptionCriteria $criteria): SubscriptionStats + { + return $this->inner->stats($criteria); + } + + /** + * @param non-empty-list $ids + * + * @return list + */ + public function claim(array $ids, SubscriptionCriteria $criteria, bool $wait = false): array + { + return $this->inner->claim($ids, $criteria, $wait); + } + + 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); + } +} 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/Engine/Listener/RetrySubscriberTest.php b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php index edb3dceb2..143b756b2 100644 --- a/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php +++ b/tests/Unit/Subscription/Engine/Listener/RetrySubscriberTest.php @@ -5,13 +5,17 @@ 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\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategy; use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository; use Patchlevel\EventSourcing\Subscription\RunMode; @@ -19,6 +23,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; @@ -147,4 +153,49 @@ 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()); + } + + public function testRoutingDoesNotResetTheRetriesOfThePartitionedMain(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] + class { + }; + + $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..41cf43e12 100644 --- a/tests/Unit/Subscription/Engine/MessageProcessorTest.php +++ b/tests/Unit/Subscription/Engine/MessageProcessorTest.php @@ -4,10 +4,17 @@ 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; +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; +use Patchlevel\EventSourcing\Subscription\Partitioner\StreamPartitioner; use Patchlevel\EventSourcing\Subscription\RunMode; use Patchlevel\EventSourcing\Subscription\Status; use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository; @@ -138,7 +145,76 @@ 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 testListenerCanStopTheSubscription(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + class { + public bool $called = false; + + #[Subscribe(ProfileVisited::class)] + public function onProfileVisited(ProfileVisited $event): void + { + $this->called = true; + } + }; + + $handled = 0; + $eventDispatcher = new EventDispatcher(); + $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]), + $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::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'); + } + + public function testPartitionGetsItsPartitionKey(): void + { + $subscriber = new #[Subscriber('test', RunMode::FromBeginning)] + #[Partition(new StreamPartitioner())] + class { + public string|null $partitionKey = null; + + #[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/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])); + } } diff --git a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php index 4b0ee2c2b..b47fc5261 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionManagerTest.php @@ -5,7 +5,10 @@ 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; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria; use Patchlevel\EventSourcing\Subscription\Store\SubscriptionStore; use Patchlevel\EventSourcing\Subscription\Store\TransactionCommitNotPossible; @@ -13,6 +16,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 @@ -116,7 +123,7 @@ public function testForEachClaimedProcessesClaimedSubscription(): void $store = $this->createMock(SubscriptionStore::class); $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('claim')->with(['foo'], $criteria)->willReturn([$subscription]); $store->expects($this->once())->method('update')->with($subscription); $store ->expects($this->once()) @@ -134,14 +141,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('find')->with($criteria)->willReturn([$subscription]); - $store->expects($this->once())->method('claim')->with('foo', $criteria)->willReturn(null); + $store->expects($this->once())->method('claim')->with(['foo'], $criteria)->willReturn([]); $store->expects($this->never())->method('update'); $store ->expects($this->once()) @@ -161,7 +168,7 @@ public function testForEachClaimedIsolatesTransientErrors(): void $store = $this->createMock(SubscriptionStore::class); $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('claim')->with(['foo'], $criteria)->willReturn([$subscription]); $store ->expects($this->once()) ->method('inLock') @@ -177,4 +184,126 @@ 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(), + new ClaimGroups(subscriptions: 2, partitions: 2), + static fn (array $subscriptions): array => array_map( + static fn (Subscription $subscription): string => $subscription->id(), + $subscriptions, + ), + ); + + self::assertSame([['a', 'b'], ['c'], ['a#1', 'a#2'], ['a#3']], $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(), + new ClaimGroups(subscriptions: 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()); + } + + 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([ + 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(), + new ClaimGroups(subscriptions: 10, positionWindow: 1000), + static fn (array $subscriptions): array => array_map( + static fn (Subscription $subscription): string => $subscription->id(), + $subscriptions, + ), + ); + + 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()), + ); + } + + public function testSupportsPartitionsOnlyWithAnInboxStore(): void + { + self::assertFalse((new SubscriptionManager($this->createMock(SubscriptionStore::class)))->supportsPartitions()); + self::assertTrue((new SubscriptionManager(new InMemorySubscriptionStore()))->supportsPartitions()); + } } diff --git a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php index d8da7ef03..d52849420 100644 --- a/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php +++ b/tests/Unit/Subscription/Engine/SubscriptionRunnerTest.php @@ -4,9 +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\OnBeforeHandleMessage; +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; @@ -14,8 +20,11 @@ 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\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; @@ -24,6 +33,8 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\EventDispatcher\EventDispatcher; +use function range; + #[CoversClass(SubscriptionRunner::class)] final class SubscriptionRunnerTest extends TestCase { @@ -165,15 +176,137 @@ 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()); + } + + 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(OnBeforeHandleMessage::class, static function (OnBeforeHandleMessage $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($this->createMock(SubscriptionStore::class)), + new SubscriptionManager($store ?? $this->createMock(SubscriptionStore::class)), $subscriberRepository, new MessageProcessor($subscriberRepository, $eventDispatcher), $eventDispatcher, 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/Partitioner/BucketPartitionerTest.php b/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php new file mode 100644 index 000000000..da29e788c --- /dev/null +++ b/tests/Unit/Subscription/Partitioner/BucketPartitionerTest.php @@ -0,0 +1,65 @@ +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'))); + } + + 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..507d99012 --- /dev/null +++ b/tests/Unit/Subscription/Partitioner/TagPartitionerTest.php @@ -0,0 +1,62 @@ +message(['student:1', 'course:42']))); + } + + public function testNoTagWithThePrefix(): void + { + $this->expectException(TagNotUnique::class); + + (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')))); + } + + 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)); + } +} diff --git a/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php b/tests/Unit/Subscription/Store/DoctrineSubscriptionStoreTest.php index d53c70dbe..47bbd06db 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; @@ -61,6 +62,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 +114,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,12 +192,16 @@ 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, + '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); @@ -225,9 +234,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 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'], 'groups' => ['default'], 'status' => ['active'], ], @@ -256,27 +266,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, + [ + '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(), @@ -285,10 +299,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 @@ -307,8 +321,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') @@ -316,7 +330,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 @@ -347,6 +361,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 +400,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,13 +494,22 @@ public function testRemove(): void { $connection = $this->createMock(Connection::class); $connection - ->expects($this->once()) - ->method('delete') - ->with('subscriptions', ['id' => 'foo']); + ->expects($this->exactly(2)) + ->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 2; + }); $store = new DoctrineSubscriptionStore($connection); - $store->remove(new Subscription('foo')); + $store->remove(new Subscription('foo'), new Subscription('bar')); } public function testInLock(): void @@ -574,10 +601,26 @@ 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(['partition_key', 'status']); + $table->addIndex(['subscriber_id', 'status', 'last_saved_at']); + + $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..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; @@ -15,6 +16,8 @@ use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; +use function array_map; + #[CoversClass(InMemorySubscriptionStore::class)] final class InMemorySubscriptionStoreTest extends TestCase { @@ -95,6 +98,56 @@ 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 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'); @@ -181,8 +234,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])), ); } @@ -190,7 +243,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 @@ -199,7 +252,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 @@ -210,4 +263,140 @@ 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(); + + $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)); + 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'); + + $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), + 'updating a partition clears its inbox up to its position', + ); + } + + 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], 'profile#processed' => [7]]); + + self::assertSame( + ['profile', 'profile#pending'], + array_map( + static fn (Subscription $s) => $s->id(), + $store->find(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 + { + $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)); + } + + 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->claim(['a', 'b', 'c', 'unknown'], new SubscriptionCriteria(status: [Status::Active])), + ), + ); + } + + public function testAddAllAndUpdateAll(): void + { + $store = new InMemorySubscriptionStore(); + + $store->add(...[new Subscription('a'), new Subscription('b')]); + + $a = $store->get('a'); + $a->changePosition(5); + $store->update(...[$a, $store->get('b')]); + + self::assertSame(5, $store->get('a')->position()); + self::assertCount(2, $store->find()); + } } 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)); 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')), + ), + ); + } +} diff --git a/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php b/tests/Unit/Subscription/Subscriber/MetadataSubscriberAccessorTest.php index 8fdde8775..0eb20703d 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,35 @@ 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())); + } + + 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)] diff --git a/tests/Unit/Subscription/SubscriptionTest.php b/tests/Unit/Subscription/SubscriptionTest.php index d28b2a314..c32519e83 100644 --- a/tests/Unit/Subscription/SubscriptionTest.php +++ b/tests/Unit/Subscription/SubscriptionTest.php @@ -5,13 +5,16 @@ 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; 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; @@ -264,4 +267,70 @@ 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()); + } + + /** @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); + + 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'); + } }