Skip to content

Commit e4656c6

Browse files
committed
parallel processing subscription engine
1 parent 135d68b commit e4656c6

37 files changed

Lines changed: 1070 additions & 816 deletions

docs/UPGRADE-4.0.md

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,85 @@ final class MigrationSubscriber
373373
}
374374
```
375375

376+
### Parallel subscription processing
377+
378+
The subscription engine now processes one subscription at a time instead of driving a single shared
379+
stream across all matching subscriptions. Each subscription is claimed individually with
380+
`FOR UPDATE SKIP LOCKED`, read from its own position with its own event filter, processed and
381+
committed in its own short transaction. Several `subscription:run` workers can now process different
382+
subscriptions in true parallel: a worker that finds a subscription locked by another worker simply
383+
skips to the next one.
384+
385+
This is mostly transparent, but a few contracts changed.
386+
387+
#### SubscriptionStore
388+
389+
`claim()` and `inLock()` are now mandatory parts of the `SubscriptionStore` interface, and the
390+
separate `LockableSubscriptionStore` interface has been removed. Every custom store has to implement
391+
both:
392+
393+
```php
394+
use Closure;
395+
use Patchlevel\EventSourcing\Subscription\Store\SubscriptionCriteria;
396+
use Patchlevel\EventSourcing\Subscription\Subscription;
397+
398+
interface SubscriptionStore
399+
{
400+
// ... get/find/add/update/remove ...
401+
402+
/**
403+
* Claim exactly one subscription via a row lock (SKIP LOCKED). Return null if the row is held
404+
* by another worker or no longer matches the criteria.
405+
*/
406+
public function claim(string $id, SubscriptionCriteria $criteria): Subscription|null;
407+
408+
/**
409+
* @param Closure():T $closure
410+
*
411+
* @return T
412+
*
413+
* @template T
414+
*/
415+
public function inLock(Closure $closure): mixed;
416+
}
417+
```
418+
419+
`find()` no longer locks the matched rows: it is now a plain, unlocked snapshot read. The locking
420+
happens per subscription inside `claim()`.
421+
422+
#### Message limit is now per subscription
423+
424+
For `Run` and `Boot`, the `limit` used to cap the total number of messages across the shared stream.
425+
Because there is no shared stream anymore, `limit` now caps the messages **per subscription**. One
426+
`subscription:run` pass therefore processes up to `limit × number of subscriptions` messages. The
427+
CLI default of `message-limit=100` now means "100 per subscription". It also defines the
428+
checkpoint/lock-hold granularity: a unit of at most `limit` messages commits atomically and releases
429+
the lock afterwards.
430+
431+
#### Error isolation
432+
433+
An error in one subscription no longer aborts the whole run. Transient errors
434+
(`Doctrine\DBAL\Exception\RetryableException`, which now includes `TransactionCommitNotPossible`)
435+
are logged and retried on the next pass without landing in `result.errors`. Any other error locks
436+
that single subscription into status `Error` and surfaces in `result.errors`, while the remaining
437+
subscriptions keep processing.
438+
439+
#### OnProcessingFinished replaced by OnSubscriptionProcessed
440+
441+
The `OnProcessingFinished` event was a single-stream concept (one shared last index) and has been
442+
removed. The runner now dispatches `OnSubscriptionProcessed($subscription, $lastIndex)` per
443+
subscription, which is where batches are flushed with that subscription's own last index. The
444+
run-wide completion hook is the already existing `OnResult` event.
445+
446+
If you dispatched or listened to `OnProcessingFinished`, switch to `OnSubscriptionProcessed`
447+
(per subscription) or `OnResult` (per command).
448+
449+
#### No global ordering across subscriptions
450+
451+
Subscriptions are processed independently, so there is no global ordering of messages across
452+
different subscriptions anymore (the per-subscription order is of course preserved). This was never
453+
guaranteed before either.
454+
376455
## Store
377456

378457
### StreamStore

docs/subscription.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -873,6 +873,14 @@ and keeping all subscriptions up to date.
873873
It also takes care that new subscribers are booted and old ones are removed again.
874874
If something breaks, the subscription engine marks the individual subscriptions as faulty and retries them.
875875

876+
Each subscription is processed independently: the engine claims one subscription at a time with a
877+
row lock, reads its own substream from its own position, processes it and
878+
commits in a short transaction before moving on to the next one. This has two consequences:
879+
880+
* An error in one subscription no aborts the whole run, the other subscriptions keep going.
881+
* Multiple workers can run the engine in parallel and share the work automatically (see
882+
[Parallel processing](#parallel-processing)).
883+
876884
:::tip
877885
The Subscription Engine was inspired by the following two blog posts:
878886

@@ -1336,7 +1344,7 @@ You can register your own listeners to hook into the engine, for example for log
13361344
| `OnHandleMessage` | A message is about to be passed to a subscriber |
13371345
| `OnHandleMessageSuccess` | A message was successfully handled by a subscriber |
13381346
| `OnHandleMessageError` | An error occurred while a subscriber was handling a message |
1339-
| `OnProcessingFinished` | The engine finished processing the stream (ended or limit reached) |
1347+
| `OnSubscriptionProcessed`| A subscription finished its stream (ended or limit reached) |
13401348
| `OnResult` | The engine finished the command and returns the result |
13411349

13421350
```php
@@ -1484,6 +1492,8 @@ $subscriptionEngine->execute(new Boot());
14841492

14851493
:::tip
14861494
You can limit the number of processed messages with the `limit` parameter: `new Boot(limit: 100)`.
1495+
The limit applies **per subscription**, so one call processes at most `limit` messages for each
1496+
booting subscription.
14871497
:::
14881498

14891499
### Run
@@ -1500,6 +1510,34 @@ $subscriptionEngine->execute(new Run());
15001510

15011511
:::tip
15021512
You can limit the number of processed messages with the `limit` parameter: `new Run(limit: 100)`.
1513+
The limit applies **per subscription**: one `Run` call processes at most `limit` messages for each
1514+
active subscription, so up to `limit × number of subscriptions` messages in total. It also defines
1515+
the checkpoint and lock-hold granularity, a unit of at most `limit` messages commits atomically and
1516+
the lock is released afterwards.
1517+
:::
1518+
1519+
### Parallel processing
1520+
1521+
Because every subscription is claimed independently by a worker,
1522+
you can start the same command in several worker processes at once.
1523+
Each worker takes the next available subscription while avoiding subscriptions that are already being processed,
1524+
so the workload is distributed across workers without any static configuration.
1525+
1526+
```php
1527+
use Patchlevel\EventSourcing\Subscription\Engine\Command\Run;
1528+
use Patchlevel\EventSourcing\Subscription\Engine\SubscriptionEngine;
1529+
1530+
// run this in as many worker processes as you like, they balance themselves
1531+
/** @var SubscriptionEngine $subscriptionEngine */
1532+
$subscriptionEngine->execute(new Run(limit: 100));
1533+
```
1534+
1535+
The per-subscription order is always preserved. There is no global ordering across different
1536+
subscriptions, but since subscriptions are independent of each other this does not matter.
1537+
1538+
:::note
1539+
This requires a database that supports `SKIP LOCKED` (PostgreSQL and MySQL do). SQLite serializes
1540+
writes anyway and simply ignores the clause, so a single worker is used there.
15031541
:::
15041542

15051543
### Teardown

src/Subscription/Engine/DefaultSubscriptionEngine.php

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ public function __construct(
6868
private readonly EventDispatcherInterface $eventDispatcher = new EventDispatcher(),
6969
iterable $argumentResolvers = [],
7070
) {
71-
$this->subscriptionManager = new SubscriptionManager($subscriptionStore);
71+
$this->subscriptionManager = new SubscriptionManager($subscriptionStore, $this->logger);
7272

7373
if ($retryStrategyRepository instanceof RetryStrategyRepository) {
7474
$this->retryStrategyRepository = $retryStrategyRepository;
@@ -94,14 +94,19 @@ public function __construct(
9494
$this->logger,
9595
);
9696

97+
$runner = new SubscriptionRunner(
98+
$this->messageLoader,
99+
$this->subscriptionManager,
100+
$this->subscriberRepository,
101+
$messageProcessor,
102+
$this->eventDispatcher,
103+
$this->logger,
104+
);
105+
97106
$this->handlers = [
98107
Boot::class => new BootHandler(
99-
$this->messageLoader,
100108
$this->subscriptionManager,
101-
$this->subscriberRepository,
102-
$messageProcessor,
103-
$this->eventDispatcher,
104-
$this->logger,
109+
$runner,
105110
),
106111
Pause::class => new PauseHandler(
107112
$this->subscriptionManager,
@@ -125,11 +130,8 @@ public function __construct(
125130
$this->logger,
126131
),
127132
Run::class => new RunHandler(
128-
$this->messageLoader,
129133
$this->subscriptionManager,
130-
$messageProcessor,
131-
$this->eventDispatcher,
132-
$this->logger,
134+
$runner,
133135
),
134136
Setup::class => new SetupHandler(
135137
$this->messageLoader,

src/Subscription/Engine/Event/OnProcessingFinished.php

Lines changed: 0 additions & 26 deletions
This file was deleted.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Subscription\Engine\Event;
6+
7+
use Patchlevel\EventSourcing\Subscription\Engine\Error;
8+
use Patchlevel\EventSourcing\Subscription\Subscription;
9+
10+
final class OnSubscriptionProcessed
11+
{
12+
/** @var list<Error> */
13+
public array $errors = [];
14+
15+
public function __construct(
16+
public readonly Subscription $subscription,
17+
public readonly int|null $lastIndex = null,
18+
) {
19+
}
20+
}

src/Subscription/Engine/EventFilteredStoreMessageLoader.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ private function events(array $subscriptions): array
5353
$eventNames = [];
5454

5555
foreach ($subscriptions as $subscription) {
56-
$subscriber = $this->subscriberRepository->get($subscription->id());
56+
$subscriber = $this->subscriberRepository->get($subscription->subscriberId());
5757

5858
if (!$subscriber instanceof MetadataSubscriberAccessor) {
5959
return [];

0 commit comments

Comments
 (0)