diff --git a/src/Projections/CantLockProjection.php b/src/Projections/CantLockProjection.php new file mode 100644 index 00000000..d15b6a29 --- /dev/null +++ b/src/Projections/CantLockProjection.php @@ -0,0 +1,11 @@ +state[$projectionId->toString()] ?? null; + } + + /** + * @throws CantLockProjection + */ + public function getCheckpointAndLock(ProjectionId $projectionId): ?Checkpoint + { + if (array_key_exists($projectionId->toString(), $this->locks)) { + throw CantLockProjection::becauseItIsAlreadyLocked($projectionId->toString()); + } + + $this->locks[$projectionId->toString()] = true; + + return $this->state[$projectionId->toString()] ?? null; + } + + public function persistCheckpointAndRelease(ProjectionId $projectionId, Checkpoint $checkpoint): void + { + unset($this->locks[$projectionId->toString()]); + $this->state[$projectionId->toString()] = $checkpoint; + } +} diff --git a/src/Projections/ProjectionHandler.php b/src/Projections/ProjectionHandler.php new file mode 100644 index 00000000..a0b1ef3f --- /dev/null +++ b/src/Projections/ProjectionHandler.php @@ -0,0 +1,34 @@ +projectionStatusRepository->getCheckpointAndLock($projectionId) ?? $initialCheckpoint; + + $messages = $this->subscription->getEventsSinceCheckpoint($cursor); + + /** @var Message $message */ + foreach ($messages as $message) { + $this->consumer->handle($message); + } + + $this->projectionStatusRepository->persistCheckpointAndRelease($projectionId, $messages->getReturn()); + } +} diff --git a/src/Projections/ProjectionHandlerTest.php b/src/Projections/ProjectionHandlerTest.php new file mode 100644 index 00000000..e213d2de --- /dev/null +++ b/src/Projections/ProjectionHandlerTest.php @@ -0,0 +1,67 @@ +persist( + new Message(new EventStub('1')), + ); + + $consumer = new CollectingMessageConsumer(); + + $projectionStatusRepository = new InMemoryProjectionStatusRepository(); + + $projectionHandler = new ProjectionHandler( + subscription: new OffsetStreamSubscriptionProvider($repository), + consumer: $consumer, + projectionStatusRepository: $projectionStatusRepository, + ); + + $projectionHandler->handle($this->getProjectionId(), OffsetCheckpoint::fromStart()); + + $this->assertCount(1, $consumer->collectedMessages()); + $this->assertEquals(OffsetCheckpoint::forOffset(1), $projectionStatusRepository->getCursor($this->getProjectionId())); + } + + /** @test */ + public function it_cant_start_a_new_play_while_projection_is_playing() + { + $repository = new InMemoryMessageRepository(); + $repository->persist( + new Message(new EventStub('1')), + ); + + $consumer = new CollectingMessageConsumer(); + + $projectionStatusRepository = new InMemoryProjectionStatusRepository(); + $projectionStatusRepository->getCheckpointAndLock($this->getProjectionId()); + + $projectionHandler = new ProjectionHandler( + subscription: new OffsetStreamSubscriptionProvider($repository), + consumer: $consumer, + projectionStatusRepository: $projectionStatusRepository, + ); + + $this->expectExceptionObject(CantLockProjection::becauseItIsAlreadyLocked('test')); + $projectionHandler->handle($this->getProjectionId(), OffsetCheckpoint::fromStart()); + } + + private function getProjectionId(): ProjectionId + { + return ProjectionId::fromString('test'); + } +} diff --git a/src/Projections/ProjectionId.php b/src/Projections/ProjectionId.php new file mode 100644 index 00000000..70a181ed --- /dev/null +++ b/src/Projections/ProjectionId.php @@ -0,0 +1,20 @@ +id; + } + + public static function fromString(string $string): self + { + return new self($string); + } +} diff --git a/src/Projections/ProjectionStatusRepository.php b/src/Projections/ProjectionStatusRepository.php new file mode 100644 index 00000000..4de79bcb --- /dev/null +++ b/src/Projections/ProjectionStatusRepository.php @@ -0,0 +1,17 @@ +aggregateRootId; + } + + public function getVersion(): int + { + return $this->aggregateRootVersion; + } + +} diff --git a/src/Subscriptions/AggregateRootIdVersionSubscriptionProvider.php b/src/Subscriptions/AggregateRootIdVersionSubscriptionProvider.php new file mode 100644 index 00000000..c6837e5a --- /dev/null +++ b/src/Subscriptions/AggregateRootIdVersionSubscriptionProvider.php @@ -0,0 +1,33 @@ +messageRepository->retrieveAllAfterVersion($checkpoint->getAggregateRootId(), $checkpoint->getVersion()); + + yield from $messages; + + $version = $messages->getReturn() ?? $checkpoint->getVersion(); + if ( ! is_int($version)) { + throw new \InvalidArgumentException('Version must be an integer'); + } + + return AggregateCheckpoint::forAggregateRootId($checkpoint->getAggregateRootId(), $version); + } +} diff --git a/src/Subscriptions/AggregateRootIdVersionSubscriptionProviderTest.php b/src/Subscriptions/AggregateRootIdVersionSubscriptionProviderTest.php new file mode 100644 index 00000000..d976e5fc --- /dev/null +++ b/src/Subscriptions/AggregateRootIdVersionSubscriptionProviderTest.php @@ -0,0 +1,35 @@ +persist( + (new Message(new EventStub('1')))->withHeader(Header::AGGREGATE_ROOT_ID, $idA)->withHeader(Header::AGGREGATE_ROOT_VERSION, 1), + (new Message(new EventStub('2')))->withHeader(Header::AGGREGATE_ROOT_ID, $idB)->withHeader(Header::AGGREGATE_ROOT_VERSION, 1), + ); + + $provider = new AggregateRootIdVersionSubscriptionProvider($messageRepository); + $messages = $provider->getEventsSinceCheckpoint(AggregateCheckpoint::forAggregateRootId($idA)); + + $actualMessages = iterator_to_array($messages, false); + $this->assertCount(1, $actualMessages); + $checkpoint = $messages->getReturn(); + $this->assertInstanceOf(AggregateCheckpoint::class, $checkpoint); + $this->assertEquals(1, $checkpoint->getVersion()); + } +} diff --git a/src/Subscriptions/Checkpoint.php b/src/Subscriptions/Checkpoint.php new file mode 100644 index 00000000..9a72f5b0 --- /dev/null +++ b/src/Subscriptions/Checkpoint.php @@ -0,0 +1,7 @@ +header($this->headerName); + if(!is_string($key)) { + return null; + } + return $key; + } +} diff --git a/src/Subscriptions/OffsetCheckpoint.php b/src/Subscriptions/OffsetCheckpoint.php new file mode 100644 index 00000000..e9c84ed6 --- /dev/null +++ b/src/Subscriptions/OffsetCheckpoint.php @@ -0,0 +1,36 @@ +offset; + } + + public function toString(): string + { + return (string) $this->offset; + } + + public static function fromString(string $string): static + { + return new static((int) $string); + } +} diff --git a/src/Subscriptions/OffsetStreamSubscriptionProvider.php b/src/Subscriptions/OffsetStreamSubscriptionProvider.php new file mode 100644 index 00000000..d24f6641 --- /dev/null +++ b/src/Subscriptions/OffsetStreamSubscriptionProvider.php @@ -0,0 +1,37 @@ +getOffset(), $this->pageLimit); + $messages = $this->messageRepository->paginate($cursor); + + yield from $messages; + + $returnedCursor = $messages->getReturn(); + + if ( ! $returnedCursor instanceof OffsetCursor) { + throw new \Exception('Invalid returned cursor type'); + } + + return OffsetCheckpoint::forOffset($returnedCursor->offset()); + } +} diff --git a/src/Subscriptions/OffsetStreamSubscriptionProviderTest.php b/src/Subscriptions/OffsetStreamSubscriptionProviderTest.php new file mode 100644 index 00000000..e774a702 --- /dev/null +++ b/src/Subscriptions/OffsetStreamSubscriptionProviderTest.php @@ -0,0 +1,31 @@ +persist( + new Message(new EventStub('1')), + new Message(new EventStub('2')), + ); + + $provider = new OffsetStreamSubscriptionProvider($messageRepository); + $messages = $provider->getEventsSinceCheckpoint(OffsetCheckpoint::forOffset(0), 100); + + $actualMessages = iterator_to_array($messages, false); + $this->assertCount(2, $actualMessages); + $checkpoint = $messages->getReturn(); + $this->assertInstanceOf(OffsetCheckpoint::class, $checkpoint); + } + + +} diff --git a/src/Subscriptions/PartitionedCheckpoint.php b/src/Subscriptions/PartitionedCheckpoint.php new file mode 100644 index 00000000..78c45dba --- /dev/null +++ b/src/Subscriptions/PartitionedCheckpoint.php @@ -0,0 +1,34 @@ +partitionKey, $offset); + } + + public function getOffset(): int + { + return $this->offset; + } + + public function getPartitionKey(): string + { + return $this->partitionKey; + } +} diff --git a/src/Subscriptions/PartitionedSubscriptionProvider.php b/src/Subscriptions/PartitionedSubscriptionProvider.php new file mode 100644 index 00000000..d8770b3b --- /dev/null +++ b/src/Subscriptions/PartitionedSubscriptionProvider.php @@ -0,0 +1,47 @@ +getOffset(), $this->pageLimit); + + $messages = $this->messageRepository->paginate($cursor); + + /** @var Message $message */ + foreach ($messages as $message) { + $partitionKey = $this->partitioner->getPartitionKey($message); + if ($partitionKey !== $checkpoint->getPartitionKey()) { + continue; + } + yield $message; + } + + $returnedCursor = $messages->getReturn(); + + if ( ! $returnedCursor instanceof OffsetCursor) { + throw new \Exception('Invalid returned cursor type'); + }; + + return $checkpoint->withOffset($returnedCursor->offset()); + } +} diff --git a/src/Subscriptions/PartitionedSubscriptionProviderTest.php b/src/Subscriptions/PartitionedSubscriptionProviderTest.php new file mode 100644 index 00000000..b6141aa8 --- /dev/null +++ b/src/Subscriptions/PartitionedSubscriptionProviderTest.php @@ -0,0 +1,36 @@ +persist( + (new Message(new EventStub('1')))->withHeader('test-partition-key', 'partition-1'), + (new Message(new EventStub('1')))->withHeader('test-partition-key', 'partition-2'), + ); + + $provider = new PartitionedSubscriptionProvider($messageRepository, new HeaderPartitioner('test-partition-key')); + $messages = $provider->getEventsSinceCheckpoint(PartitionedCheckpoint::fromOrigin('partition-1')); + + $actualMessages = iterator_to_array($messages, false); + $this->assertCount(1, $actualMessages); + $checkpoint = $messages->getReturn(); + $this->assertInstanceOf(PartitionedCheckpoint::class, $checkpoint); + + // Cursor has offset of 2, since filtered out messages need to be counted in the stream offset + $this->assertEquals(2, $checkpoint->getOffset()); + + } +} diff --git a/src/Subscriptions/Partitioner.php b/src/Subscriptions/Partitioner.php new file mode 100644 index 00000000..8e6ed5eb --- /dev/null +++ b/src/Subscriptions/Partitioner.php @@ -0,0 +1,10 @@ + + */ + public function getEventsSinceCheckpoint(Checkpoint $checkpoint): Generator; +}