From 842c546481d8faa6b6d4b2cb1d9a033d7de628da Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sun, 5 Apr 2026 19:32:01 +0000 Subject: [PATCH 1/4] feat: open-source global ProjectionV2 with sync/async event-driven execution Make globally-tracked (non-partitioned) projections available without enterprise licence while keeping advanced features enterprise-only. --- .../EcotoneProjectionExecutorBuilder.php | 10 + .../PartitionProviderRegistryModule.php | 5 + .../Config/ProjectingAttributeModule.php | 12 + .../Projecting/Config/ProjectingModule.php | 14 +- .../ProjectionStateStorageRegistryModule.php | 5 + .../Config/StreamSourceRegistryModule.php | 5 + .../tests/Projecting/ProjectingTest.php | 4 +- .../Projecting/ProjectionV2EnterpriseTest.php | 419 ++++++++++++++++++ 8 files changed, 469 insertions(+), 5 deletions(-) create mode 100644 packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php diff --git a/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php b/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php index 9c2f2e56c..1dada6b57 100644 --- a/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php +++ b/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php @@ -51,6 +51,8 @@ public function __construct( private ?string $resetChannel = null, private ?int $rebuildPartitionBatchSize = null, private ?string $rebuildAsyncChannelName = null, + private bool $hasRebuild = false, + private bool $hasDeployment = false, ) { if ($this->partitionHeader && ! $this->automaticInitialization) { throw new ConfigurationException("Cannot set partition header for projection {$this->projectionName} with automatic initialization disabled"); @@ -150,6 +152,14 @@ public function rebuildAsyncChannelName(): ?string return $this->rebuildAsyncChannelName; } + public function isOpenSourceEligible(): bool + { + return ! $this->isPartitioned() + && $this->backfillAsyncChannelName === null + && ! $this->hasRebuild + && ! $this->hasDeployment; + } + public function compile(MessagingContainerBuilder $builder): Definition|Reference { $routerProcessor = $this->buildExecutionRouter($builder); diff --git a/packages/Ecotone/src/Projecting/Config/PartitionProviderRegistryModule.php b/packages/Ecotone/src/Projecting/Config/PartitionProviderRegistryModule.php index 4e9a4477f..4ef3e5a6d 100644 --- a/packages/Ecotone/src/Projecting/Config/PartitionProviderRegistryModule.php +++ b/packages/Ecotone/src/Projecting/Config/PartitionProviderRegistryModule.php @@ -19,6 +19,7 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Handler\InterfaceToCallRegistry; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Projecting\Attribute\PartitionProvider as PartitionProviderAttribute; use Ecotone\Projecting\Attribute\ProjectionV2; use Ecotone\Projecting\PartitionProviderReference; @@ -56,6 +57,10 @@ public static function create(AnnotationFinder $annotationFinder, InterfaceToCal public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void { + if (! empty($this->userlandPartitionProviderReferences) && ! $messagingConfiguration->isRunningForEnterpriseLicence()) { + throw LicensingException::create('Custom #[PartitionProvider] implementations require Ecotone Enterprise licence.'); + } + $partitionProviderReferences = ExtensionObjectResolver::resolve( PartitionProviderReference::class, $extensionObjects diff --git a/packages/Ecotone/src/Projecting/Config/ProjectingAttributeModule.php b/packages/Ecotone/src/Projecting/Config/ProjectingAttributeModule.php index a03b02d84..1a9b08aba 100644 --- a/packages/Ecotone/src/Projecting/Config/ProjectingAttributeModule.php +++ b/packages/Ecotone/src/Projecting/Config/ProjectingAttributeModule.php @@ -31,6 +31,7 @@ use Ecotone\Messaging\Handler\ServiceActivator\MessageProcessorActivatorBuilder; use Ecotone\Messaging\Handler\ServiceActivator\ServiceActivatorBuilder; use Ecotone\Messaging\Support\Assert; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Modelling\Attribute\EventHandler; use Ecotone\Modelling\Attribute\NamedEvent; use Ecotone\Projecting\Attribute\Partitioned; @@ -105,6 +106,8 @@ public static function create(AnnotationFinder $annotationRegistrationService, I partitioned: $partitionAttribute !== null, rebuildPartitionBatchSize: $rebuildAttribute?->partitionBatchSize, rebuildAsyncChannelName: $rebuildAttribute?->asyncChannelName, + hasRebuild: $rebuildAttribute !== null, + hasDeployment: $projectionDeployment !== null, ); $asyncAttribute = self::getProjectionAsynchronousAttribute($annotationRegistrationService, $projectionClassName); @@ -180,6 +183,15 @@ public static function create(AnnotationFinder $annotationRegistrationService, I public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void { + if (! $messagingConfiguration->isRunningForEnterpriseLicence()) { + if (! empty($this->pollingProjections)) { + throw LicensingException::create('#[Polling] projections require Ecotone Enterprise licence.'); + } + if (! empty($this->eventStreamingProjections)) { + throw LicensingException::create('#[Streaming] projections require Ecotone Enterprise licence.'); + } + } + foreach ($this->lifecycleHandlers as $lifecycleHandler) { $messagingConfiguration->registerMessageHandler($lifecycleHandler); } diff --git a/packages/Ecotone/src/Projecting/Config/ProjectingModule.php b/packages/Ecotone/src/Projecting/Config/ProjectingModule.php index 57581bb0d..ef79ccb40 100644 --- a/packages/Ecotone/src/Projecting/Config/ProjectingModule.php +++ b/packages/Ecotone/src/Projecting/Config/ProjectingModule.php @@ -15,7 +15,6 @@ use Ecotone\Messaging\Attribute\WithoutDatabaseTransaction; use Ecotone\Messaging\Attribute\WithoutMessageCollector; use Ecotone\Messaging\Config\Configuration; -use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Config\Container\AttributeDefinition; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\InterfaceToCallReference; @@ -23,6 +22,7 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Config\ServiceConfiguration; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Messaging\Endpoint\Interceptor\TerminationListener; use Ecotone\Messaging\Gateway\MessagingEntrypointService; use Ecotone\Messaging\Handler\InterfaceToCallRegistry; @@ -67,7 +67,11 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $projectionBuilders = ExtensionObjectResolver::resolve(ProjectionExecutorBuilder::class, $extensionObjects); if (! empty($projectionBuilders) && ! $messagingConfiguration->isRunningForEnterpriseLicence()) { - throw ConfigurationException::create('Projections are part of Ecotone Enterprise. To use projections, please acquire an enterprise licence.'); + foreach ($projectionBuilders as $builder) { + if (! $builder instanceof EcotoneProjectionExecutorBuilder || ! $builder->isOpenSourceEligible()) { + throw LicensingException::create('Projections with enterprise features (Partitioned, Streaming, Polling, ProjectionRebuild, ProjectionDeployment, async backfill) require Ecotone Enterprise licence.'); + } + } } $messagingConfiguration->registerServiceDefinition( @@ -122,10 +126,14 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO $asyncAttribute = $projectionBuilder instanceof EcotoneProjectionExecutorBuilder ? $projectionBuilder->getAsyncAttribute() : null; if ($asyncAttribute !== null) { + $endpointAnnotations = $asyncAttribute->getEndpointAnnotations(); + if ($messagingConfiguration->isRunningForEnterpriseLicence()) { + $endpointAnnotations = array_merge($endpointAnnotations, [new WithoutDatabaseTransaction(), new WithoutMessageCollector()]); + } $handlerBuilder = $handlerBuilder->withEndpointAnnotations([ AttributeDefinition::fromObject(new Asynchronous( $asyncAttribute->getChannelName(), - array_merge($asyncAttribute->getEndpointAnnotations(), [new WithoutDatabaseTransaction(), new WithoutMessageCollector()]), + $endpointAnnotations, )), ]); } diff --git a/packages/Ecotone/src/Projecting/Config/ProjectionStateStorageRegistryModule.php b/packages/Ecotone/src/Projecting/Config/ProjectionStateStorageRegistryModule.php index 2d827a9f8..a9311028a 100644 --- a/packages/Ecotone/src/Projecting/Config/ProjectionStateStorageRegistryModule.php +++ b/packages/Ecotone/src/Projecting/Config/ProjectionStateStorageRegistryModule.php @@ -21,6 +21,7 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Handler\InterfaceToCallRegistry; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Projecting\Attribute\ProjectionV2; use Ecotone\Projecting\Attribute\StateStorage as StateStorageAttribute; use Ecotone\Projecting\InMemory\InMemoryProjectionStateStorage; @@ -58,6 +59,10 @@ public static function create(AnnotationFinder $annotationFinder, InterfaceToCal public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void { + if (! empty($this->userlandStateStorageReferences) && ! $messagingConfiguration->isRunningForEnterpriseLicence()) { + throw LicensingException::create('Custom #[StateStorage] implementations require Ecotone Enterprise licence.'); + } + $stateStorageReferences = ExtensionObjectResolver::resolve( ProjectionStateStorageReference::class, $extensionObjects diff --git a/packages/Ecotone/src/Projecting/Config/StreamSourceRegistryModule.php b/packages/Ecotone/src/Projecting/Config/StreamSourceRegistryModule.php index 01876de13..eb64e189f 100644 --- a/packages/Ecotone/src/Projecting/Config/StreamSourceRegistryModule.php +++ b/packages/Ecotone/src/Projecting/Config/StreamSourceRegistryModule.php @@ -20,6 +20,7 @@ use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Handler\InterfaceToCallRegistry; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Projecting\Attribute\StreamSource as StreamSourceAttribute; use Ecotone\Projecting\StreamSource; use Ecotone\Projecting\StreamSourceReference; @@ -48,6 +49,10 @@ public static function create(AnnotationFinder $annotationFinder, InterfaceToCal public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void { + if (! empty($this->userlandStreamSourceReferences) && ! $messagingConfiguration->isRunningForEnterpriseLicence()) { + throw LicensingException::create('Custom #[StreamSource] implementations require Ecotone Enterprise licence.'); + } + $streamSourceReferences = ExtensionObjectResolver::resolve( StreamSourceReference::class, $extensionObjects diff --git a/packages/Ecotone/tests/Projecting/ProjectingTest.php b/packages/Ecotone/tests/Projecting/ProjectingTest.php index 222d8147c..1be30ad8e 100644 --- a/packages/Ecotone/tests/Projecting/ProjectingTest.php +++ b/packages/Ecotone/tests/Projecting/ProjectingTest.php @@ -16,6 +16,7 @@ use Ecotone\Messaging\Channel\SimpleMessageChannelBuilder; use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Config\ModulePackageList; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Messaging\Config\ServiceConfiguration; use Ecotone\Messaging\Endpoint\ExecutionPollingMetadata; use Ecotone\Messaging\Endpoint\Interceptor\PcntlTerminationListener; @@ -537,8 +538,7 @@ public function init(): void public function test_it_throws_exception_when_no_licence(): void { - $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage('Projections are part of Ecotone Enterprise. To use projections, please acquire an enterprise licence.'); + $this->expectException(LicensingException::class); $projection = new #[ProjectionV2('test'), FromStream('test_stream')] class { #[EventHandler('*')] diff --git a/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php b/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php new file mode 100644 index 000000000..98d7729f9 --- /dev/null +++ b/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php @@ -0,0 +1,419 @@ +handledEvents[] = $event; + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + + $ecotone->withEvents([Event::createWithType('test-event', ['name' => 'Test'])]); + $ecotone->publishEventWithRoutingKey('trigger', []); + + $this->assertCount(1, $projection->handledEvents); + } + + public function test_global_async_projection_works_without_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), Asynchronous('async')] class { + public array $handledEvents = []; + + #[EventHandler('*')] + public function handle(array $event): void + { + $this->handledEvents[] = $event; + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->addExtensionObject(SimpleMessageChannelBuilder::createQueueChannel('async')) + ); + + $this->assertNotNull($ecotone); + } + + public function test_sync_backfill_works_without_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), ProjectionBackfill] class { + public array $handledEvents = []; + + #[EventHandler('*')] + public function handle(array $event): void + { + $this->handledEvents[] = $event; + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + + $this->assertNotNull($ecotone); + } + + public function test_lifecycle_hooks_work_without_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream')] class { + public bool $initialized = false; + public bool $deleted = false; + public bool $reset = false; + public bool $flushed = false; + public array $handledEvents = []; + + #[EventHandler('*')] + public function handle(array $event): void + { + $this->handledEvents[] = $event; + } + + #[ProjectionInitialization] + public function init(): void + { + $this->initialized = true; + } + + #[ProjectionDelete] + public function remove(): void + { + $this->deleted = true; + } + + #[ProjectionReset] + public function resetState(): void + { + $this->reset = true; + } + + #[ProjectionFlush] + public function flush(): void + { + $this->flushed = true; + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + + $this->assertNotNull($ecotone); + } + + public function test_projection_execution_batch_size_works_without_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), ProjectionExecution(eventLoadingBatchSize: 50)] class { + public array $handledEvents = []; + + #[EventHandler('*')] + public function handle(array $event): void + { + $this->handledEvents[] = $event; + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + + $this->assertNotNull($ecotone); + } + + public function test_partitioned_projection_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), Partitioned] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_streaming_projection_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), Streaming('streaming_channel')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ); + } + + public function test_polling_projection_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), Polling('test_endpoint')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_async_backfill_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), ProjectionBackfill(asyncChannelName: 'backfill_channel')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_rebuild_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), ProjectionRebuild] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_deployment_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream'), ProjectionDeployment(manualKickOff: true)] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_custom_stream_source_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $customStreamSource = new #[Attribute\StreamSource] class implements StreamSource { + public function canHandle(string $projectionName): bool + { + return true; + } + + public function load(string $projectionName, ?string $lastPosition, int $count, ?string $partitionKey = null): StreamPage + { + return new StreamPage([], '0'); + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class, $customStreamSource::class], + [$projection, $customStreamSource], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_custom_state_storage_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $customStateStorage = new #[Attribute\StateStorage] class implements ProjectionStateStorage { + public function canHandle(string $projectionName): bool + { + return true; + } + + public function loadPartition(string $projectionName, ?string $partitionKey = null, bool $lock = true): ?ProjectionPartitionState + { + return null; + } + + public function initPartition(string $projectionName, ?string $partitionKey = null): ?ProjectionPartitionState + { + return null; + } + + public function savePartition(ProjectionPartitionState $projectionState): void + { + } + + public function delete(string $projectionName): void + { + } + + public function init(string $projectionName): void + { + } + + public function beginTransaction(): Transaction + { + return new NoOpTransaction(); + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class, $customStateStorage::class], + [$projection, $customStateStorage], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } + + public function test_custom_partition_provider_requires_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream')] class { + #[EventHandler('*')] + public function handle(array $event): void + { + } + }; + + $customPartitionProvider = new #[Attribute\PartitionProvider] class implements PartitionProvider { + public function canHandle(string $projectionName): bool + { + return true; + } + + public function count(StreamFilter $filter): int + { + return 0; + } + + public function partitions(StreamFilter $filter, ?int $limit = null, int $offset = 0): iterable + { + return []; + } + }; + + $this->expectException(LicensingException::class); + + EcotoneLite::bootstrapFlowTesting( + [$projection::class, $customPartitionProvider::class], + [$projection, $customPartitionProvider], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + } +} From fa51c7d1f53487284e11420a18cad4829914726b Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sun, 5 Apr 2026 19:48:47 +0000 Subject: [PATCH 2/4] feat: gate ProjectionName header behind enterprise licence Do not pass ProjectingHeaders::PROJECTION_NAME to projection handler methods without enterprise licence. This header enables blue-green deployment scenarios and should remain enterprise-only. --- .../EcotoneProjectionExecutorBuilder.php | 2 ++ .../Projecting/EcotoneProjectorExecutor.php | 33 +++++++++++-------- .../Projecting/ProjectionV2EnterpriseTest.php | 29 ++++++++++++++++ 3 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php b/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php index 1dada6b57..278adb6aa 100644 --- a/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php +++ b/packages/Ecotone/src/Projecting/Config/EcotoneProjectionExecutorBuilder.php @@ -13,6 +13,7 @@ use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\MessagingContainerBuilder; use Ecotone\Messaging\Config\Container\Reference; +use Ecotone\Messaging\Config\LicenceDecider; use Ecotone\Messaging\Gateway\MessagingEntrypointService; use Ecotone\Messaging\Handler\ChannelResolver; use Ecotone\Messaging\Handler\Router\RouterProcessor; @@ -168,6 +169,7 @@ public function compile(MessagingContainerBuilder $builder): Definition|Referenc new Reference(MessageHeadersPropagatorInterceptor::class), $this->projectionName, $routerProcessor, + Reference::to(LicenceDecider::class), $this->initChannel, $this->deleteChannel, $this->flushChannel, diff --git a/packages/Ecotone/src/Projecting/EcotoneProjectorExecutor.php b/packages/Ecotone/src/Projecting/EcotoneProjectorExecutor.php index 215b17035..2205677c1 100644 --- a/packages/Ecotone/src/Projecting/EcotoneProjectorExecutor.php +++ b/packages/Ecotone/src/Projecting/EcotoneProjectorExecutor.php @@ -8,6 +8,7 @@ namespace Ecotone\Projecting; use Ecotone\Messaging\Channel\QueueChannel; +use Ecotone\Messaging\Config\LicenceDecider; use Ecotone\Messaging\Gateway\MessagingEntrypointService; use Ecotone\Messaging\Handler\MessageProcessor; use Ecotone\Messaging\MessageHeaders; @@ -22,8 +23,9 @@ class EcotoneProjectorExecutor implements ProjectorExecutor public function __construct( private MessagingEntrypointService $messagingEntrypoint, private MessageHeadersPropagatorInterceptor $messageHeadersPropagatorInterceptor, - private string $projectionName, // this is required for event stream emitter so it can create a stream with this name + private string $projectionName, private MessageProcessor $routerProcessor, + private LicenceDecider $licenceDecider, private ?string $initChannel = null, private ?string $deleteChannel = null, private ?string $flushChannel = null, @@ -37,7 +39,9 @@ public function project(Event $event, mixed $userState = null): mixed $metadata = $event->getMetadata(); $metadata[ProjectingHeaders::PROJECTION_STATE] = $userState ?? null; $metadata[ProjectingHeaders::PROJECTION_EVENT_NAME] = $event->getEventName(); - $metadata[ProjectingHeaders::PROJECTION_NAME] = $this->projectionName; + if ($this->licenceDecider->hasEnterpriseLicence()) { + $metadata[ProjectingHeaders::PROJECTION_NAME] = $this->projectionName; + } $metadata[ProjectingHeaders::PROJECTION_LIVE] = $this->isLive; $metadata[MessageHeaders::STREAM_BASED_SOURCED] = true; // this one is required for correct header propagation in EventStreamEmitter... $metadata[MessageHeaders::REPLY_CHANNEL] = $responseQueue = new QueueChannel('response_channel'); @@ -65,37 +69,30 @@ function () use ($requestMessage) { public function init(): void { if ($this->initChannel) { - $this->messagingEntrypoint->sendWithHeaders([], [ - ProjectingHeaders::PROJECTION_NAME => $this->projectionName, - ], $this->initChannel); + $this->messagingEntrypoint->sendWithHeaders([], $this->withProjectionName([]), $this->initChannel); } } public function delete(): void { if ($this->deleteChannel) { - $this->messagingEntrypoint->sendWithHeaders([], [ - ProjectingHeaders::PROJECTION_NAME => $this->projectionName, - ], $this->deleteChannel); + $this->messagingEntrypoint->sendWithHeaders([], $this->withProjectionName([]), $this->deleteChannel); } } public function flush(mixed $userState = null): void { if ($this->flushChannel) { - $this->messagingEntrypoint->sendWithHeaders([], [ - ProjectingHeaders::PROJECTION_NAME => $this->projectionName, + $this->messagingEntrypoint->sendWithHeaders([], $this->withProjectionName([ ProjectingHeaders::PROJECTION_STATE => $userState, - ], $this->flushChannel); + ]), $this->flushChannel); } } public function reset(?string $partitionKey = null): void { if ($this->resetChannel) { - $headers = [ - ProjectingHeaders::PROJECTION_NAME => $this->projectionName, - ]; + $headers = $this->withProjectionName([]); if ($partitionKey !== null) { $headers[ProjectingHeaders::REBUILD_PARTITION_KEY] = $partitionKey; @@ -110,4 +107,12 @@ public function reset(?string $partitionKey = null): void $this->messagingEntrypoint->sendWithHeaders([], $headers, $this->resetChannel); } } + + private function withProjectionName(array $headers): array + { + if ($this->licenceDecider->hasEnterpriseLicence()) { + $headers[ProjectingHeaders::PROJECTION_NAME] = $this->projectionName; + } + return $headers; + } } diff --git a/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php b/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php index 98d7729f9..f4543dea5 100644 --- a/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php +++ b/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php @@ -18,6 +18,7 @@ use Ecotone\Modelling\Event; use Ecotone\Projecting\Attribute\Partitioned; use Ecotone\Projecting\Attribute\Polling; +use Ecotone\Projecting\Attribute\ProjectionName; use Ecotone\Projecting\Attribute\ProjectionBackfill; use Ecotone\Projecting\Attribute\ProjectionDeployment; use Ecotone\Projecting\Attribute\ProjectionExecution; @@ -34,6 +35,8 @@ use Ecotone\Projecting\StreamPage; use Ecotone\Projecting\StreamSource; use Ecotone\Projecting\Transaction; +use Ecotone\Messaging\Handler\MethodInvocationException; +use Ecotone\Messaging\MessageHeaders; use PHPUnit\Framework\TestCase; /** @@ -416,4 +419,30 @@ public function partitions(StreamFilter $filter, ?int $limit = null, int $offset ->withSkippedModulePackageNames(ModulePackageList::allPackages()) ); } + + public function test_projection_name_header_is_not_available_without_licence(): void + { + $projection = new #[ProjectionV2('test'), FromStream('test_stream')] class { + public array $handledEvents = []; + + #[EventHandler('*')] + public function handle(array $event, #[ProjectionName] string $projectionName): void + { + $this->handledEvents[] = ['event' => $event, 'projectionName' => $projectionName]; + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$projection::class], + [$projection], + configuration: ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackages()) + ); + + $ecotone->withEvents([Event::createWithType('test-event', ['name' => 'Test'], [MessageHeaders::EVENT_AGGREGATE_ID => '1'])]); + + $this->expectException(MethodInvocationException::class); + + $ecotone->publishEventWithRoutingKey('trigger', []); + } } From 5e9a74424cc3790b89b9835f41383bfa1b290230 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Sun, 5 Apr 2026 19:50:25 +0000 Subject: [PATCH 3/4] test: verify projection name exception references missing header --- .../tests/Projecting/ProjectionV2EnterpriseTest.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php b/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php index f4543dea5..02620b5aa 100644 --- a/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php +++ b/packages/Ecotone/tests/Projecting/ProjectionV2EnterpriseTest.php @@ -441,8 +441,11 @@ public function handle(array $event, #[ProjectionName] string $projectionName): $ecotone->withEvents([Event::createWithType('test-event', ['name' => 'Test'], [MessageHeaders::EVENT_AGGREGATE_ID => '1'])]); - $this->expectException(MethodInvocationException::class); - - $ecotone->publishEventWithRoutingKey('trigger', []); + try { + $ecotone->publishEventWithRoutingKey('trigger', []); + self::fail('Should have thrown exception'); + } catch (MethodInvocationException $e) { + self::assertStringContainsString('projection.name', $e->getMessage(), 'Exception should mention missing projection.name header. Got: ' . $e->getMessage()); + } } } From 9e4c3fd6a7b7a5cc1e46c4fd211dcd6b6f46e9f4 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Tue, 7 Apr 2026 08:45:49 +0200 Subject: [PATCH 4/4] Projection foundation under open licence --- .../Ecotone/src/Projecting/Attribute/ProjectionBackfill.php | 2 +- .../Ecotone/src/Projecting/Attribute/ProjectionExecution.php | 2 +- packages/Ecotone/src/Projecting/Attribute/ProjectionFlush.php | 2 +- packages/Ecotone/src/Projecting/Attribute/ProjectionV2.php | 2 +- .../src/Projecting/Config/EcotoneProjectionExecutorBuilder.php | 2 +- .../src/Projecting/Config/PartitionProviderRegistryModule.php | 2 +- .../Ecotone/src/Projecting/Config/ProjectingAttributeModule.php | 2 +- .../Ecotone/src/Projecting/Config/ProjectingConsoleCommands.php | 2 +- packages/Ecotone/src/Projecting/Config/ProjectingModule.php | 2 +- .../src/Projecting/Config/ProjectingModuleRoutingExtension.php | 2 +- .../Ecotone/src/Projecting/Config/ProjectionExecutorBuilder.php | 2 +- .../Projecting/Config/ProjectionStateStorageRegistryModule.php | 2 +- .../src/Projecting/Config/StreamFilterRegistryModule.php | 2 +- .../src/Projecting/Config/StreamSourceRegistryModule.php | 2 +- packages/Ecotone/src/Projecting/EcotoneProjectorExecutor.php | 2 +- .../src/Projecting/InMemory/InMemoryProjectionRegistry.php | 2 +- .../src/Projecting/InMemory/InMemoryProjectionStateStorage.php | 2 +- packages/Ecotone/src/Projecting/InMemory/InMemoryProjector.php | 2 +- .../Ecotone/src/Projecting/InMemory/InMemoryStreamSource.php | 2 +- packages/Ecotone/src/Projecting/NoOpTransaction.php | 2 +- .../Ecotone/src/Projecting/PartitionBatchExecutorHandler.php | 2 +- packages/Ecotone/src/Projecting/PartitionProvider.php | 2 +- packages/Ecotone/src/Projecting/PartitionProviderReference.php | 2 +- packages/Ecotone/src/Projecting/PartitionProviderRegistry.php | 2 +- packages/Ecotone/src/Projecting/ProjectingHeaders.php | 2 +- packages/Ecotone/src/Projecting/ProjectingManager.php | 2 +- .../Ecotone/src/Projecting/ProjectionInitializationStatus.php | 2 +- packages/Ecotone/src/Projecting/ProjectionPartitionState.php | 2 +- packages/Ecotone/src/Projecting/ProjectionRegistry.php | 2 +- packages/Ecotone/src/Projecting/ProjectionStateStorage.php | 2 +- .../Ecotone/src/Projecting/ProjectionStateStorageReference.php | 2 +- .../Ecotone/src/Projecting/ProjectionStateStorageRegistry.php | 2 +- packages/Ecotone/src/Projecting/ProjectorExecutor.php | 2 +- packages/Ecotone/src/Projecting/SinglePartitionProvider.php | 2 +- packages/Ecotone/src/Projecting/StreamFilter.php | 2 +- packages/Ecotone/src/Projecting/StreamFilterRegistry.php | 2 +- packages/Ecotone/src/Projecting/StreamPage.php | 2 +- packages/Ecotone/src/Projecting/StreamSource.php | 2 +- packages/Ecotone/src/Projecting/StreamSourceReference.php | 2 +- packages/Ecotone/src/Projecting/StreamSourceRegistry.php | 2 +- packages/Ecotone/src/Projecting/Transaction.php | 2 +- packages/PdoEventSourcing/src/Config/ProophProjectingModule.php | 2 +- .../src/Database/ProjectionStateTableManager.php | 2 +- .../Projecting/PartitionState/DbalProjectionStateStorage.php | 2 +- .../src/Projecting/PartitionState/DbalTransaction.php | 2 +- packages/PdoEventSourcing/src/Projecting/StreamEvent.php | 2 +- .../Projecting/StreamSource/EventStoreGlobalStreamSource.php | 2 +- .../src/Projecting/StreamSource/GapAwarePosition.php | 2 +- 48 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/Ecotone/src/Projecting/Attribute/ProjectionBackfill.php b/packages/Ecotone/src/Projecting/Attribute/ProjectionBackfill.php index af60033ee..3f991bffe 100644 --- a/packages/Ecotone/src/Projecting/Attribute/ProjectionBackfill.php +++ b/packages/Ecotone/src/Projecting/Attribute/ProjectionBackfill.php @@ -1,7 +1,7 @@