Skip to content

Commit 3796db9

Browse files
committed
add subscription cleanup feature
1 parent 68bc84e commit 3796db9

21 files changed

Lines changed: 811 additions & 19 deletions

docs/pages/subscription.md

Lines changed: 147 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,10 +293,11 @@ final class DoStuffSubscriber
293293
}
294294
}
295295
```
296+
296297
### Setup and Teardown
297298

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

302303
```php
@@ -319,12 +320,6 @@ final class ProfileProjector
319320
sprintf('CREATE TABLE IF NOT EXISTS %s (id VARCHAR PRIMARY KEY, name VARCHAR NOT NULL);', self::TABLE),
320321
);
321322
}
322-
323-
#[Teardown]
324-
public function drop(): void
325-
{
326-
$this->connection->executeStatement(sprintf('DROP TABLE IF EXISTS %s;', self::TABLE));
327-
}
328323
}
329324
```
330325
!!! danger
@@ -344,6 +339,83 @@ final class ProfileProjector
344339
Most databases have a limit on the length of the table/collection name.
345340
The limit is usually 64 characters.
346341

342+
### Teardown
343+
344+
Subscribers can have one `teardown` method that is executed when the subscription is removed.
345+
For this there is the attributes `Teardown`.
346+
347+
```php
348+
use Doctrine\DBAL\Connection;
349+
use Patchlevel\EventSourcing\Attribute\Projector;
350+
use Patchlevel\EventSourcing\Attribute\Setup;
351+
use Patchlevel\EventSourcing\Attribute\Teardown;
352+
353+
#[Projector(self::TABLE)]
354+
final class ProfileProjector
355+
{
356+
private const TABLE = 'profile_v1';
357+
358+
private Connection $connection;
359+
360+
#[Teardown]
361+
public function drop(): void
362+
{
363+
$this->connection->executeStatement(sprintf('DROP TABLE IF EXISTS %s;', self::TABLE));
364+
}
365+
}
366+
```
367+
!!! danger
368+
369+
MySQL and MariaDB don't support transactions for DDL statements.
370+
So you must use a different database connection in your projectors,
371+
otherwise you will get an error when the subscription tries to create the table.
372+
373+
!!! warning
374+
375+
A teardown can only be performed for a subscription if the subscriber with that subscriber ID still exists.
376+
377+
!!! note
378+
379+
You can not mix the `cleanup` method with the `teardown` method.
380+
381+
### Cleanup
382+
383+
The cleanup option allows you to delete the subscription from the database after the subscription has finished.
384+
This is especially useful for projectors,
385+
386+
```php
387+
use Doctrine\DBAL\Connection;
388+
use Patchlevel\EventSourcing\Attribute\Cleanup;
389+
use Patchlevel\EventSourcing\Attribute\Projector;
390+
use Patchlevel\EventSourcing\Attribute\Setup;
391+
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DropIndexTask;
392+
393+
#[Projector(self::TABLE)]
394+
final class ProfileProjector
395+
{
396+
private const TABLE = 'profile_v1';
397+
398+
private Connection $connection;
399+
400+
#[Cleanup]
401+
public function drop(): array
402+
{
403+
return [
404+
new DropIndexTask(self::TABLE)
405+
];
406+
}
407+
}
408+
```
409+
410+
!!! note
411+
412+
You can not mix the `cleanup` method with the `teardown` method.
413+
414+
!!! tip
415+
416+
You can create your own cleanup tasks and add them to the array.
417+
For more information, see [Cleanup Handler](#cleanup-handler).
418+
347419
### On Failed
348420

349421
The subscription engine has a [retry strategy](#retry-strategy) to retry subscriptions that have an error.
@@ -918,7 +990,63 @@ $retryStrategyRepository = new RetryStrategyRepository([
918990
!!! tip
919991

920992
You can change the default retry strategy by define the name in the constructor as second parameter.
993+
994+
### Cleanup Handler
995+
996+
You can also create your own cleanup tasks.
997+
The subscription engine will call the method `__invoke` on the task object.
998+
For example, you can create a task that drops a collection from a MongoDB database.
999+
1000+
First, create a task class, that holds the collection name.
1001+
1002+
```php
1003+
final class DropCollection {
1004+
public function __construct(
1005+
public readonly string $collectionName
1006+
) {}
1007+
}
1008+
```
1009+
1010+
!!! warning
1011+
1012+
The task class must be serializable. It will be stored in the subscription store.
1013+
1014+
Then create a handler that supports this task.
1015+
1016+
```php
1017+
use MongoDb\Database;
1018+
use Patchlevel\EventSourcing\Subscription\Cleanup\CleanupHandler;
1019+
1020+
final class MongodbCleanupHandler implements CleanupHandler {
1021+
public function __construct(
1022+
private readonly Database $database
1023+
) {}
1024+
1025+
public function __invoke(object $task): void
1026+
{
1027+
if ($task instanceof DropCollection) {
1028+
$this->database->dropCollection($task->collectionName);
1029+
}
1030+
}
9211031

1032+
public function supports(object $task): bool
1033+
{
1034+
return $task instanceof DropCollection;
1035+
}
1036+
}
1037+
```
1038+
1039+
Last step, you need to add the handler to the `DefaultCleaner`, that is used by the subscription engine.
1040+
1041+
```php
1042+
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;
1043+
1044+
$cleaner = new DefaultCleaner([
1045+
new DbalCleanupHandler($projectionConnection),
1046+
new MongodbCleanupHandler($mongodbDatabase),
1047+
]);
1048+
```
1049+
9221050
### Subscriber Accessor
9231051

9241052
The subscriber accessor repository is responsible for providing the subscribers to the subscription engine.
@@ -938,30 +1066,41 @@ $subscriberAccessorRepository = new MetadataSubscriberAccessorRepository([
9381066
$subscriber3,
9391067
]);
9401068
```
1069+
9411070
### Subscription Engine
9421071

9431072
Now we can create the subscription engine and plug together the necessary services.
9441073
The message loader is needed to load the messages, the Subscription Store to store the subscription state
9451074
and we need the subscriber accessor repository. Optionally, we can also pass a retry strategy.
1075+
Finally, if we want to use the cleanup feature, we need to pass the cleanup handlers.
9461076

9471077
```php
1078+
use Doctrine\DBAL\Connection;
9481079
use Patchlevel\EventSourcing\Subscription\Engine\DefaultSubscriptionEngine;
9491080
use Patchlevel\EventSourcing\Subscription\Engine\MessageLoader;
9501081
use Patchlevel\EventSourcing\Subscription\RetryStrategy\RetryStrategyRepository;
9511082
use Patchlevel\EventSourcing\Subscription\Store\DoctrineSubscriptionStore;
9521083
use Patchlevel\EventSourcing\Subscription\Subscriber\MetadataSubscriberAccessorRepository;
1084+
use Patchlevel\EventSourcing\Subscription\Cleanup\Dbal\DbalCleanupHandler;
1085+
use Patchlevel\EventSourcing\Subscription\Cleanup\DefaultCleaner;
9531086

9541087
/**
9551088
* @var MessageLoader $messageLoader
9561089
* @var DoctrineSubscriptionStore $subscriptionStore
9571090
* @var MetadataSubscriberAccessorRepository $subscriberAccessorRepository
9581091
* @var RetryStrategyRepository $retryStrategyRepository
1092+
* @var LoggerInterface $logger
1093+
* @var Connection $projectionConnection
9591094
*/
9601095
$subscriptionEngine = new DefaultSubscriptionEngine(
9611096
$messageLoader,
9621097
$subscriptionStore,
9631098
$subscriberAccessorRepository,
9641099
$retryStrategyRepository, // optional, if not set the default retry strategy is used
1100+
$logger, // optional
1101+
new DefaultCleaner([
1102+
new DbalCleanupHandler($projectionConnection)
1103+
]), // required, if you want to use the cleanup feature
9651104
);
9661105
```
9671106
### 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
}
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\Subscription\Cleanup;
6+
7+
use Patchlevel\EventSourcing\Subscription\Subscription;
8+
9+
interface Cleaner
10+
{
11+
public function cleanup(Subscription $subscription): void;
12+
}

0 commit comments

Comments
 (0)