Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions docs/dynamic-consistency-boundary.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,67 @@ final class GuestAlreadyCheckedIn extends BasicProjection
}
}
```
### Checkpoints

Over time, a projection may have to load a lot of events.
If an event contains the whole state the projection needs, you can mark its apply method with `#[Checkpoint]`.
The decision model then only loads events from the last of these events onwards,
looked up with the tags of the projection.

```php
use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Checkpoint;
use Patchlevel\EventSourcing\Identifier\Uuid;
use Patchlevel\EventSourcing\Projection\BasicProjection;

final class NumberOfGuestsInHotel extends BasicProjection
{
public function __construct(
private readonly Uuid $hotelId,
) {
}

public function initialState(): int
{
return 0;
}

/** @return list<string> */
protected function tagFilter(): array
{
return ["hotel:{$this->hotelId->toString()}"];
}

#[Apply]
#[Checkpoint]
public function applyGuestsCounted(int $state, GuestsCounted $event): int
{
return $event->numberOfGuests;
}

#[Apply]
public function applyGuestIsCheckedIn(int $state, GuestIsCheckedIn $event): int
{
return $state + 1;
}

#[Apply]
public function applyGuestIsCheckedOut(int $state, GuestIsCheckedOut $event): int
{
return $state - 1;
}
}
```
:::note
A checkpoint only affects the projection that declares it.
Other projections in the same decision model and subscriptions still get all events they need.
Nothing is archived and the append condition is the same as without a checkpoint.
:::

:::tip
If a projection only ever needs its last event, you can override `lastEventIsEnough()` to return `true` instead.
:::

## Define handlers

We’ll implement three command handlers corresponding to our commands.
Expand Down
18 changes: 18 additions & 0 deletions src/Attribute/Checkpoint.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Attribute;

use Attribute;

/**
* Marks an apply method of a projection whose event contains the whole state the projection needs.
* Events before the last of these events are not loaded anymore.
*
* @experimental
*/
#[Attribute(Attribute::TARGET_METHOD)]
final class Checkpoint
{
}
7 changes: 5 additions & 2 deletions src/DecisionModel/StoreDecisionModelBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@

namespace Patchlevel\EventSourcing\DecisionModel;

use Patchlevel\EventSourcing\Projection\CheckpointPositions;
use Patchlevel\EventSourcing\Projection\CompositeProjection;
use Patchlevel\EventSourcing\Projection\Projection;
use Patchlevel\EventSourcing\Store\AppendCondition;
use Patchlevel\EventSourcing\Store\AppendStore;

use function max;
use function min;

/** @experimental */
final class StoreDecisionModelBuilder implements DecisionModelBuilder
Expand All @@ -23,10 +25,11 @@
public function build(
array $projections,
): DecisionModel {
$projection = new CompositeProjection($projections);
$from = CheckpointPositions::resolve($this->store, $projections);
$projection = new CompositeProjection($projections, $from);

$query = $projection->query();
$stream = $this->store->query($query);
$stream = $this->store->query($query, $from === [] ? 0 : min($from));

Check warning on line 32 in src/DecisionModel/StoreDecisionModelBuilder.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "IncrementInteger": @@ @@ $projection = new CompositeProjection($projections, $from); $query = $projection->query(); - $stream = $this->store->query($query, $from === [] ? 0 : min($from)); + $stream = $this->store->query($query, $from === [] ? 1 : min($from)); $state = $projection->initialState();

$state = $projection->initialState();

Expand Down
10 changes: 10 additions & 0 deletions src/Projection/ApplyMethodDetectionError.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,4 +80,14 @@ public static function argumentTypeIsMissing(string $methodName): self
),
);
}

public static function checkpointWithoutApply(string $methodName): self
{
return new self(
sprintf(
'The method "%s" has a #[Checkpoint] attribute, but no #[Apply] attribute.',
$methodName,
),
);
}
}
23 changes: 22 additions & 1 deletion src/Projection/BasicProjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Patchlevel\EventSourcing\Projection;

use Patchlevel\EventSourcing\Attribute\Apply;
use Patchlevel\EventSourcing\Attribute\Checkpoint;
use Patchlevel\EventSourcing\Message\Message;
use Patchlevel\EventSourcing\Store\SubQuery;
use ReflectionClass;
Expand All @@ -24,7 +25,7 @@
* @template S = mixed
* @implements Projection<S>
*/
abstract class BasicProjection implements Projection, SubQueryProvider
abstract class BasicProjection implements Projection, SubQueryProvider, CheckpointProvider
{
/** @var array<class-string, string>|null $applyMethods */
private array|null $applyMethods = null;
Expand Down Expand Up @@ -67,6 +68,22 @@
return $this->subQuery;
}

/** @return list<class-string> */
public function checkpointEvents(): array
{
$checkpointEvents = [];

foreach ($this->applyMethods() as $eventClass => $methodName) {
if ((new ReflectionMethod($this, $methodName))->getAttributes(Checkpoint::class) === []) {
continue;
}

$checkpointEvents[] = $eventClass;
}

return $checkpointEvents;

Check warning on line 84 in src/Projection/BasicProjection.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ArrayOneItem": @@ @@ $checkpointEvents[] = $eventClass; } - return $checkpointEvents; + return count($checkpointEvents) > 1 ? array_slice($checkpointEvents, 0, 1, true) : $checkpointEvents; } /** @return list<class-string> */
}

/** @return list<class-string> */
protected function eventTypeFilter(): array
{
Expand All @@ -88,6 +105,10 @@
$attributes = $method->getAttributes(Apply::class);

if ($attributes === []) {
if ($method->getAttributes(Checkpoint::class) !== []) {
throw ApplyMethodDetectionError::checkpointWithoutApply($method->getName());
}

continue;
}

Expand Down
68 changes: 68 additions & 0 deletions src/Projection/CheckpointPositions.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Projection;

use Patchlevel\EventSourcing\Store\AppendStore;
use Patchlevel\EventSourcing\Store\Query;
use Patchlevel\EventSourcing\Store\SubQuery;

use function array_map;
use function array_values;
use function max;

/** @internal */
final class CheckpointPositions
{
/**
* Returns for each projection the index from which on it needs events.
* That is the index of its last checkpoint event, or 0 if it has none.
*
* @param array<string, Projection> $projections
*
* @return array<string, positive-int|0>
*/
public static function resolve(AppendStore $store, array $projections): array
{
$positions = array_map(static fn (): int => 0, $projections);
$lookups = [];

foreach ($projections as $name => $projection) {
if (!$projection instanceof CheckpointProvider || !$projection instanceof SubQueryProvider) {
continue;
}

$checkpointEvents = $projection->checkpointEvents();

if ($checkpointEvents === []) {
continue;
}

$subQuery = $projection->subQuery();

$lookups[$name] = new SubQuery(
$subQuery->tags,
$checkpointEvents,
$subQuery->streamName,
true,
);
}

if ($lookups === []) {
return $positions;
}

foreach ($store->query(new Query(...array_values($lookups))) as $index => $message) {
foreach ($lookups as $name => $lookup) {
if (!$lookup->match($message)) {
continue;
}

$positions[$name] = max($positions[$name], $index);
}
}

return $positions;
}
}
17 changes: 17 additions & 0 deletions src/Projection/CheckpointProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Projection;

/** @experimental */
interface CheckpointProvider
{
/**
* Events which contain the whole state the projection needs.
* Everything before the last of these events is not loaded anymore.
*
* @return list<class-string>
*/
public function checkpointEvents(): array;
}
14 changes: 13 additions & 1 deletion src/Projection/CompositeProjection.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,21 @@
namespace Patchlevel\EventSourcing\Projection;

use Patchlevel\EventSourcing\Message\Message;
use Patchlevel\EventSourcing\Store\Header\IndexHeader;
use Patchlevel\EventSourcing\Store\Query;

use function array_map;

/** @experimental */
final class CompositeProjection
{
/** @param array<string, Projection> $projections */
/**
* @param array<string, Projection> $projections
* @param array<string, positive-int|0> $from messages before this index are not applied to the projection
*/
public function __construct(
private readonly array $projections,
private readonly array $from = [],
) {
}

Expand Down Expand Up @@ -51,6 +56,13 @@
public function apply(mixed $state, Message $message): mixed
{
foreach ($this->projections as $name => $projection) {
if (
($this->from[$name] ?? 0) > 0

Check warning on line 60 in src/Projection/CompositeProjection.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "DecrementInteger": @@ @@ { foreach ($this->projections as $name => $projection) { if ( - ($this->from[$name] ?? 0) > 0 + ($this->from[$name] ?? -1) > 0 && $message->header(IndexHeader::class)->index < $this->from[$name] ) { continue;
&& $message->header(IndexHeader::class)->index < $this->from[$name]

Check warning on line 61 in src/Projection/CompositeProjection.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "LessThanNegotiation": @@ @@ foreach ($this->projections as $name => $projection) { if ( ($this->from[$name] ?? 0) > 0 - && $message->header(IndexHeader::class)->index < $this->from[$name] + && $message->header(IndexHeader::class)->index >= $this->from[$name] ) { continue; }
) {
continue;

Check warning on line 63 in src/Projection/CompositeProjection.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Continue_": @@ @@ ($this->from[$name] ?? 0) > 0 && $message->header(IndexHeader::class)->index < $this->from[$name] ) { - continue; + break; } $state[$name] = $projection->apply($state[$name], $message);
}

$state[$name] = $projection->apply($state[$name], $message);
}

Expand Down
7 changes: 5 additions & 2 deletions src/Projection/StoreProjectionBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

use Patchlevel\EventSourcing\Store\AppendStore;

use function min;

/** @experimental */
final class StoreProjectionBuilder implements ProjectionBuilder
{
Expand All @@ -22,10 +24,11 @@
public function build(
array $projections,
): array {
$projection = new CompositeProjection($projections);
$from = CheckpointPositions::resolve($this->store, $projections);
$projection = new CompositeProjection($projections, $from);

$query = $projection->query();
$stream = $this->store->query($query);
$stream = $this->store->query($query, $from === [] ? 0 : min($from));

Check warning on line 31 in src/Projection/StoreProjectionBuilder.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "IncrementInteger": @@ @@ $projection = new CompositeProjection($projections, $from); $query = $projection->query(); - $stream = $this->store->query($query, $from === [] ? 0 : min($from)); + $stream = $this->store->query($query, $from === [] ? 1 : min($from)); $state = $projection->initialState();

$state = $projection->initialState();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Tests\Benchmark\DynamicConsistencyBoundary\Events;

use Patchlevel\EventSourcing\Attribute\Event;
use Patchlevel\EventSourcing\Attribute\EventTag;

#[Event('course.capacity_changed')]
final class CourseCapacityChanged
{
public function __construct(
#[EventTag(prefix: 'course')]
public readonly string $courseId,
public readonly int $capacity,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Tests\Benchmark\DynamicConsistencyBoundary\Events;

use Patchlevel\EventSourcing\Attribute\Event;
use Patchlevel\EventSourcing\Attribute\EventTag;

#[Event('course.defined')]
final class CourseDefined
{
public function __construct(
#[EventTag(prefix: 'course')]
public readonly string $courseId,
public readonly int $capacity,
) {
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php

declare(strict_types=1);

namespace Patchlevel\EventSourcing\Tests\Benchmark\DynamicConsistencyBoundary\Events;

use Patchlevel\EventSourcing\Attribute\Event;
use Patchlevel\EventSourcing\Attribute\EventTag;

#[Event('invoice.created')]
final class InvoiceCreated
{
public function __construct(
#[EventTag(prefix: 'invoice')]
public readonly int $invoiceNumber,
) {
}
}
Loading
Loading