Skip to content

Commit dd900c2

Browse files
authored
Merge pull request #807 from patchlevel/subscriber-cleanup-hook
add subscription cleanup feature
2 parents 52f45dc + 3b26145 commit dd900c2

26 files changed

Lines changed: 1397 additions & 21 deletions

docs/pages/subscription.md

Lines changed: 159 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -293,23 +293,21 @@ final class DoStuffSubscriber
293293
}
294294
}
295295
```
296-
297296
##### Custom Resolvers
298297

299298
You can provide your own argument resolvers by implementing the `ArgumentResolver` interface.
300299
This can be useful for providing direct access to custom headers or other data.
301300

302-
### Setup and Teardown
301+
### Setup
303302

304-
Subscribers can have one `setup` and `teardown` method that is executed when the subscription is created or deleted.
305-
For this there are the attributes `Setup` and `Teardown`. The method name itself doesn't matter.
303+
Subscribers can have one `setup` method that is executed when the subscription is created.
304+
For this there is the attributes `Setup`. The method name itself doesn't matter.
306305
This is especially helpful for projectors, as they can create the necessary structures for the projection here.
307306

308307
```php
309308
use Doctrine\DBAL\Connection;
310309
use Patchlevel\EventSourcing\Attribute\Projector;
311310
use Patchlevel\EventSourcing\Attribute\Setup;
312-
use Patchlevel\EventSourcing\Attribute\Teardown;
313311

314312
#[Projector(self::TABLE)]
315313
final class ProfileProjector
@@ -325,6 +323,41 @@ final class ProfileProjector
325323
sprintf('CREATE TABLE IF NOT EXISTS %s (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);', self::TABLE),
326324
);
327325
}
326+
}
327+
```
328+
!!! danger
329+
330+
MySQL and MariaDB don't support transactions for DDL statements.
331+
So you must use a different database connection in your projectors,
332+
otherwise you will get an error when the subscription tries to create the table.
333+
334+
!!! warning
335+
336+
If you change the subscriber id, you must also change the table/collection name.
337+
The subscription engine will create a new subscription with the new subscriber id.
338+
That means the setup method will be called again and the table/collection will conflict with the old existing projection.
339+
340+
!!! note
341+
342+
Most databases have a limit on the length of the table/collection name.
343+
The limit is usually 64 characters.
344+
345+
### Teardown
346+
347+
Subscribers can have one `teardown` method that is executed when the subscription is removed.
348+
For this there is the attributes `Teardown`.
349+
350+
```php
351+
use Doctrine\DBAL\Connection;
352+
use Patchlevel\EventSourcing\Attribute\Projector;
353+
use Patchlevel\EventSourcing\Attribute\Teardown;
354+
355+
#[Projector(self::TABLE)]
356+
final class ProfileProjector
357+
{
358+
private const TABLE = 'profile_v1';
359+
360+
private Connection $connection;
328361

329362
#[Teardown]
330363
public function drop(): void
@@ -341,14 +374,58 @@ final class ProfileProjector
341374

342375
!!! warning
343376

344-
If you change the subscriber id, you must also change the table/collection name.
345-
The subscription engine will create a new subscription with the new subscriber id.
346-
That means the setup method will be called again and the table/collection will conflict with the old existing projection.
377+
A teardown can only be performed for a subscription if the code for the subscriber with that subscriber ID still exists.
378+
A another option is to use the `Cleanup` option.
347379

348380
!!! note
349381

350-
Most databases have a limit on the length of the table/collection name.
351-
The limit is usually 64 characters.
382+
You can not mix the `cleanup` method with the `teardown` method.
383+
384+
### Cleanup
385+
386+
Alternativ, you can use a `cleanup` method for cleanup tasks.
387+
Unlike Teardown, this method is called when the subscription is created.
388+
The tasks are then saved in the Subscription Store.
389+
When removing the subscription, the subscriber is not necessary anymore,
390+
as the cleanup can be performed using the tasks in the store and an associated external handler.
391+
392+
```php
393+
use Doctrine\DBAL\Connection;
394+
use Patchlevel\EventSourcing\Attribute\Cleanup;
395+
use Patchlevel\EventSourcing\Attribute\Projector;
396+
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropIndexTask;
397+
398+
#[Projector(self::TABLE)]
399+
final class ProfileProjector
400+
{
401+
private const TABLE = 'profile_v1';
402+
403+
private Connection $connection;
404+
405+
#[Cleanup]
406+
public function drop(): array
407+
{
408+
return [new DropIndexTask(self::TABLE)];
409+
}
410+
}
411+
```
412+
!!! note
413+
414+
You can not mix the `cleanup` method with the `teardown` method.
415+
416+
#### Dbal Cleanup Tasks
417+
418+
Default, we provide the following cleanup tasks for `doctrine/dbal`:
419+
420+
| Task | Description |
421+
|-----------------|------------------------------|
422+
| `DropIndexTask` | Drops an index from a table. |
423+
| `DropTableTask` | Drops a table. |
424+
425+
!!! tip
426+
427+
You can create your own cleanup tasks and handler.
428+
For more information, see [Cleanup Handler](#cleanup-handler).
352429

353430
### On Failed
354431

@@ -925,6 +1002,70 @@ $retryStrategyRepository = new RetryStrategyRepository([
9251002

9261003
You can change the default retry strategy by define the name in the constructor as second parameter.
9271004

1005+
### Cleanup Handler
1006+
1007+
You can also create your own cleanup tasks with associated handlers.
1008+
First, create a task class that has all necessary information for the task.
1009+
In our example, we create a task that deletes a collection from MongoDB.
1010+
1011+
```php
1012+
final class DropCollection
1013+
{
1014+
public function __construct(
1015+
public readonly string $collectionName,
1016+
) {
1017+
}
1018+
}
1019+
```
1020+
!!! warning
1021+
1022+
The task class must be serializable. It will be stored in the subscription store.
1023+
1024+
The next step is to create a handler for the task.
1025+
The handler must implement the `CleanupHandler` interface.
1026+
1027+
```php
1028+
use MongoDb\Database;
1029+
use Patchlevel\EventSourcing\Subscription\Cleanup\CleanupTaskHandler;
1030+
1031+
final class MongodbCleanupTaskHandler implements CleanupTaskHandler
1032+
{
1033+
public function __construct(
1034+
private readonly Database $database,
1035+
) {
1036+
}
1037+
1038+
public function __invoke(object $task): void
1039+
{
1040+
if (!($task instanceof DropCollection)) {
1041+
return;
1042+
}
1043+
1044+
$this->database->dropCollection($task->collectionName);
1045+
}
1046+
1047+
public function supports(object $task): bool
1048+
{
1049+
return $task instanceof DropCollection;
1050+
}
1051+
}
1052+
```
1053+
Lastly, we have to add the new handler to `DefaultCleaner`,
1054+
which is responsible for cleaning up subscriptions.
1055+
1056+
```php
1057+
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler;
1058+
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;
1059+
1060+
$cleaner = new DefaultCleaner([
1061+
new DbalCleanupTaskHandler($projectionConnection),
1062+
new MongodbCleanupTaskHandler($mongodbDatabase),
1063+
]);
1064+
```
1065+
!!! warning
1066+
1067+
You need to pass the Cleaner to the Subscription Engine.
1068+
9281069
### Subscriber Accessor
9291070

9301071
The subscriber accessor repository is responsible for providing the subscribers to the subscription engine.
@@ -949,8 +1090,12 @@ $subscriberAccessorRepository = new MetadataSubscriberAccessorRepository([
9491090
Now we can create the subscription engine and plug together the necessary services.
9501091
The message loader is needed to load the messages, the Subscription Store to store the subscription state
9511092
and we need the subscriber accessor repository. Optionally, we can also pass a retry strategy.
1093+
Finally, if we want to use the cleanup feature, we need to pass the cleanup handlers.
9521094

9531095
```php
1096+
use Doctrine\DBAL\Connection;
1097+
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupTaskHandler;
1098+
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;
9541099
use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine;
9551100
use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader;
9561101
use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository;
@@ -962,12 +1107,16 @@ use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorR
9621107
* @var DoctrineSubscriptionStore $subscriptionStore
9631108
* @var MetadataSubscriberAccessorRepository $subscriberAccessorRepository
9641109
* @var RetryStrategyRepository $retryStrategyRepository
1110+
* @var LoggerInterface $logger
1111+
* @var Connection $projectionConnection
9651112
*/
9661113
$subscriptionEngine = new DefaultSubscriptionEngine(
9671114
$messageLoader,
9681115
$subscriptionStore,
9691116
$subscriberAccessorRepository,
9701117
$retryStrategyRepository, // optional, if not set the default retry strategy is used
1118+
$logger, // optional
1119+
new DefaultCleaner([new DbalCleanupTaskHandler($projectionConnection)]), // optional but required if you want to use the cleanup feature
9711120
);
9721121
```
9731122
### Catch up Subscription Engine

phpstan-baseline.neon

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -156,18 +156,18 @@ parameters:
156156
count: 1
157157
path: src/Subscription/Store/DoctrineSubscriptionStore.php
158158

159+
-
160+
message: '#^Parameter \#9 \$cleanupTasks of class Patchlevel\\EventSourcing\\Subscription\\Subscription constructor expects list\<object\>\|null, mixed given\.$#'
161+
identifier: argument.type
162+
count: 1
163+
path: src/Subscription/Store/DoctrineSubscriptionStore.php
164+
159165
-
160166
message: '#^Cannot cast mixed to string\.$#'
161167
identifier: cast.string
162168
count: 3
163169
path: src/Subscription/ThrowableToErrorContextTransformer.php
164170

165-
-
166-
message: '#^Property Patchlevel\\EventSourcing\\Tests\\Benchmark\\BasicImplementation\\ProfileWithCommands\:\:\$id is never read, only written\.$#'
167-
identifier: property.onlyWritten
168-
count: 1
169-
path: tests/Benchmark/BasicImplementation/ProfileWithCommands.php
170-
171171
-
172172
message: '#^Cannot access offset ''name'' on array\<string, mixed\>\|false\.$#'
173173
identifier: offsetAccess.nonOffsetAccessible

src/Attribute/Cleanup.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_METHOD)]
10+
final class Cleanup
11+
{
12+
}

src/Metadata/Subscriber/AttributeSubscriberMetadataFactory.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
namespace Patchlevel\EventSourcing\Metadata\Subscriber;
66

7+
use Patchlevel\EventSourcing\Attribute\Cleanup;
78
use Patchlevel\EventSourcing\Attribute\OnFailed;
89
use Patchlevel\EventSourcing\Attribute\RetryStrategy;
910
use Patchlevel\EventSourcing\Attribute\Setup;
@@ -45,6 +46,7 @@ public function metadata(string $subscriber): SubscriberMetadata
4546
$subscribeMethods = [];
4647
$setupMethod = null;
4748
$teardownMethod = null;
49+
$cleanupMethod = null;
4850
$failedMethod = null;
4951

5052
foreach ($methods as $method) {
@@ -90,6 +92,26 @@ public function metadata(string $subscriber): SubscriberMetadata
9092
$setupMethod = $method->getName();
9193
}
9294

95+
if ($method->getAttributes(Cleanup::class)) {
96+
if ($cleanupMethod !== null) {
97+
throw new DuplicateCleanupMethod(
98+
$subscriber,
99+
$cleanupMethod,
100+
$method->getName(),
101+
);
102+
}
103+
104+
if ($teardownMethod !== null) {
105+
throw new MixedTeardownAndCleanupMethods(
106+
$subscriber,
107+
$teardownMethod,
108+
$method->getName(),
109+
);
110+
}
111+
112+
$cleanupMethod = $method->getName();
113+
}
114+
93115
if (!$method->getAttributes(Teardown::class)) {
94116
continue;
95117
}
@@ -102,6 +124,14 @@ public function metadata(string $subscriber): SubscriberMetadata
102124
);
103125
}
104126

127+
if ($cleanupMethod !== null) {
128+
throw new MixedTeardownAndCleanupMethods(
129+
$subscriber,
130+
$method->getName(),
131+
$cleanupMethod,
132+
);
133+
}
134+
105135
$teardownMethod = $method->getName();
106136
}
107137

@@ -118,6 +148,7 @@ public function metadata(string $subscriber): SubscriberMetadata
118148
$teardownMethod,
119149
$failedMethod,
120150
$this->retryStrategy($reflector),
151+
$cleanupMethod,
121152
);
122153

123154
$this->subscriberMetadata[$subscriber] = $metadata;
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Metadata\Subscriber;
6+
7+
use Patchlevel\EventSourcing\Metadata\MetadataException;
8+
9+
use function sprintf;
10+
11+
final class DuplicateCleanupMethod extends MetadataException
12+
{
13+
/** @param class-string $subscriber */
14+
public function __construct(string $subscriber, string $fistMethod, string $secondMethod)
15+
{
16+
parent::__construct(
17+
sprintf(
18+
'Two methods "%s" and "%s" on the subscriber "%s" have been marked as "cleanup" methods. Only one method can be defined like this.',
19+
$fistMethod,
20+
$secondMethod,
21+
$subscriber,
22+
),
23+
);
24+
}
25+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Patchlevel\EventSourcing\Metadata\Subscriber;
6+
7+
use Patchlevel\EventSourcing\Metadata\MetadataException;
8+
9+
use function sprintf;
10+
11+
final class MixedTeardownAndCleanupMethods extends MetadataException
12+
{
13+
public function __construct(
14+
string $subscriber,
15+
string $teardownMethod,
16+
string $cleanupMethod,
17+
) {
18+
parent::__construct(
19+
sprintf(
20+
'The subscriber "%s" has a "teardown" method "%s" and a "cleanup" method "%s". Only one of them can be defined.',
21+
$subscriber,
22+
$teardownMethod,
23+
$cleanupMethod,
24+
),
25+
);
26+
}
27+
}

src/Metadata/Subscriber/SubscriberMetadata.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ public function __construct(
1919
public readonly string|null $teardownMethod = null,
2020
public readonly string|null $failedMethod = null,
2121
public readonly string|null $retryStrategy = null,
22+
public readonly string|null $cleanupMethod = null,
2223
) {
2324
}
2425
}

0 commit comments

Comments
 (0)