From 88098e7a60ebc8d6799e3ed21a85d87d59e32167 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 30 Apr 2026 11:05:40 +0200 Subject: [PATCH 01/13] docs: design for external-message tenant resolver Capture the agreed design for #[WithTenantResolver(expression: ...)] that lets handlers derive the tenant header from inbound headers (e.g. kafka_topic) in time for multi-tenant connection switching, addressing the case where externally-arriving async messages lack a tenant header. --- ...external-message-tenant-resolver-design.md | 167 ++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md diff --git a/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md b/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md new file mode 100644 index 000000000..b198f2e85 --- /dev/null +++ b/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md @@ -0,0 +1,167 @@ +# External-Message Tenant Resolver — Design + +**Date:** 2026-04-30 +**Status:** Draft, awaiting review +**Branch:** `feat/external-message-tenant-mapping` + +## Problem + +In a multi-tenant Ecotone application that consumes messages from an external (non-Ecotone) producer like Kafka, the inbound message envelope carries no `tenant` header — only headers that identify the source (e.g. `kafka_topic`). Users want to derive the tenant from one of those source headers so that the multi-tenant connection switching can pick the right database for the rest of the handler invocation. + +The natural attempt — `#[AddHeader('tenant', expression: "headers['kafka_topic']")]` on the handler — does not work for externally-arriving messages. The handler-level `#[AddHeader]` is registered as a *before-send* interceptor (`EndpointHeadersInterceptorModule`, precedence -3000) which fires when a producer-side gateway sends the message into the handler. For external messages, no producer-side gateway is involved: the message is polled from the broker and dispatched to the handler chain directly. The async dispatch chain then runs: + +``` +PollToGatewayTaskExecutor + → propagateTenant Around (-2001) ← reads tenant header, none found, proceeds + → CollectorSender Around + → ObjectManagerInterceptor (-1998) ← calls getConnectionFactory() + → throws "Lack of context about tenant in Message Headers" + (handler is never reached, so #[AddHeader] never runs) +``` + +A reproduction is in `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php`. + +## Existing workaround that works today + +A `#[Before(pointcut: AsynchronousRunningEndpoint::class, changeHeaders: true)]` interceptor with default precedence already solves the problem, because Before interceptors are positioned in the chain *before* all Around interceptors regardless of precedence (`ChainedMessageProcessorBuilder::compileProcessor` lines 59-72). Verified in `BeforeInterceptorTenantWorkaroundTest`. + +This works but is verbose and global — every async endpoint in the system pays the cost of the resolver class instantiation and pointcut evaluation, even handlers that have nothing to do with multi-tenancy. Users would need to teach this idiom themselves; nothing in the multi-tenant API points them to it. + +## Solution + +Introduce a method-level attribute `#[WithTenantResolver(expression: "...")]` and have the multi-tenant module automatically register a Before interceptor whose pointcut targets that attribute. Only handlers that opt in via the attribute pay any cost; for everyone else the chain is unchanged. + +### User-facing API + +```php +final class OrderService +{ + #[Asynchronous('orders_topic')] + #[CommandHandler('processExternalOrder')] + #[WithTenantResolver(expression: "headers['kafka_topic']")] + public function process(string $payload, #[Headers] array $headers): void + { + // tenant header is set from kafka_topic before multi-tenant switching fires + } +} +``` + +The expression has access to `payload` and `headers` (same context as `#[AddHeader]`). Service-backed mappers work via the existing expression-language `reference()` function: + +```php +#[WithTenantResolver(expression: "reference('topicToTenantMapper').map(headers['kafka_topic'])")] +``` + +### Components + +#### 1. Attribute — `Ecotone\Dbal\Attribute\WithTenantResolver` + +```php +#[Attribute(Attribute::TARGET_METHOD)] +final class WithTenantResolver +{ + public function __construct(public string $expression) {} + + public function getExpression(): string + { + return $this->expression; + } +} +``` + +Single field, single getter. No defaults — expression is required. + +#### 2. Resolver service — `Ecotone\Dbal\MultiTenant\MultiTenantHeaderResolver` + +```php +final class MultiTenantHeaderResolver +{ + public function __construct( + private string $tenantHeaderName, + private ExpressionEvaluationService $expressionEvaluationService, + ) {} + + public function resolve(Message $message, ?WithTenantResolver $resolverAttribute): array + { + if ($resolverAttribute === null) { + return []; + } + if ($message->getHeaders()->containsKey($this->tenantHeaderName)) { + return []; + } + + $value = $this->expressionEvaluationService->evaluate( + $resolverAttribute->getExpression(), + [ + 'payload' => $message->getPayload(), + 'headers' => $message->getHeaders()->headers(), + ] + ); + + return $value === null ? [] : [$this->tenantHeaderName => $value]; + } +} +``` + +The `?WithTenantResolver` parameter is the matched endpoint annotation, populated by the framework via the same mechanism `EndpointHeadersInterceptor::addMetadata` uses to receive `?AddHeader`. + +#### 3. Wiring — `MultiTenantConnectionFactoryModule::prepare()` + +Inside the existing per-config loop, register the resolver service and a Before method interceptor whose pointcut is the new attribute: + +```php +$resolverReference = 'multi_tenant_header_resolver.' . $multiTenantConfig->getReferenceName(); +$messagingConfiguration->registerServiceDefinition( + $resolverReference, + new Definition(MultiTenantHeaderResolver::class, [ + $multiTenantConfig->getTenantHeaderName(), + Reference::to(ExpressionEvaluationService::REFERENCE), + ]) +); + +$messagingConfiguration->registerBeforeMethodInterceptor( + MethodInterceptorBuilder::create( + Reference::to($resolverReference), + $interfaceToCallRegistry->getFor(MultiTenantHeaderResolver::class, 'resolve'), + Precedence::DEFAULT_PRECEDENCE, + WithTenantResolver::class, + true + ) +); +``` + +The pointcut is `WithTenantResolver::class`, which means the interceptor only fires on methods carrying that attribute. Methods without it are unaffected. + +### Behaviour rules + +| Condition | Outcome | +|---|---| +| Method has no `#[WithTenantResolver]` | Interceptor never fires. | +| Method has the attribute, message already has the tenant header | Skip — explicit headers win (preserves internal flows). | +| Expression returns `null` | Skip — let the existing "Lack of context" error surface so misconfigurations fail loudly. | +| Expression throws | Propagate. User's expression bugs surface immediately. | + +### Scope (what this design does *not* cover) + +- **Multiple multi-tenant configurations:** if more than one `MultiTenantConfiguration` exists, each config registers its own resolver instance. They will all fire on a `WithTenantResolver`-tagged method, each writing its own tenant header name. v1 assumes the single-config setup that the existing module's lines 80-85 already privileges. If multi-config support becomes a requirement later, the attribute can grow an optional `reference:` field naming the target config. +- **Producer-side derivation:** the existing `#[AddHeader]` already covers internal flows. This design targets the inbound-async case only, and the pointcut reflects that. +- **Documenting the underlying `#[Before]` recipe (option A):** explicitly out of scope per the agreed direction. + +## Test strategy + +- **Reproduction test** (already in repo): `ExternalMessageTenantMappingTest::test_externally_arriving_message_without_tenant_header_should_be_resolvable` — currently fails with "Lack of context about tenant"; should pass once the handler is annotated with `#[WithTenantResolver(expression: "headers['source_topic']")]`. +- **Workaround verification test** (already in repo): `BeforeInterceptorTenantWorkaroundTest` — passes today; documents that the underlying `#[Before]` mechanism is the foundation. Worth keeping as a regression check on the chain ordering it depends on. +- **New tests for the attribute:** + - Header is derived from a single source header (the happy path the user asked for). + - Service-backed expression via `reference('mapper').map(...)`. + - Existing tenant header is preserved when present. + - Expression returning `null` falls through to the existing error path. + - A method *without* `#[WithTenantResolver]` is unaffected (no interceptor invocation, no behaviour change). + +## Files touched + +- `packages/Dbal/src/Attribute/WithTenantResolver.php` — new +- `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` — new +- `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` — register resolver and Before interceptor inside the existing per-config loop +- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/` — fixture handler(s) using the new attribute +- `packages/Dbal/tests/Integration/MultiTenant/` — new test class covering the attribute behaviours; existing reproduction updated to use the attribute and assert success From 48b1d96540b582c93e2f760d2d04936a36aac5f1 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 30 Apr 2026 13:16:29 +0200 Subject: [PATCH 02/13] docs: pivot tenant-resolver design to per-method WithTenantResolver attribute Drop the runtime-resolution / Enterprise mechanism in favour of a method-level attribute placed alongside #[KafkaConsumer] / #[RabbitConsumer]. Channel-adapter modules already propagate getAllAnnotationDefinitions() to the gateway, so a Before interceptor with pointcut WithTenantResolver::class fires only on tagged consumer methods with zero overhead elsewhere and no core framework changes. --- ...external-message-tenant-resolver-design.md | 56 +++++++++++-------- 1 file changed, 33 insertions(+), 23 deletions(-) diff --git a/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md b/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md index b198f2e85..f6ccfad7d 100644 --- a/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md +++ b/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md @@ -21,25 +21,28 @@ PollToGatewayTaskExecutor A reproduction is in `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php`. -## Existing workaround that works today +## Existing primitives we'll use -A `#[Before(pointcut: AsynchronousRunningEndpoint::class, changeHeaders: true)]` interceptor with default precedence already solves the problem, because Before interceptors are positioned in the chain *before* all Around interceptors regardless of precedence (`ChainedMessageProcessorBuilder::compileProcessor` lines 59-72). Verified in `BeforeInterceptorTenantWorkaroundTest`. +Two pieces of the existing framework make this clean: -This works but is verbose and global — every async endpoint in the system pays the cost of the resolver class instantiation and pointcut evaluation, even handlers that have nothing to do with multi-tenancy. Users would need to teach this idiom themselves; nothing in the multi-tenant API points them to it. +1. **Before-method interceptors run before all Around interceptors at the same intercepted method**, regardless of precedence (`ChainedMessageProcessorBuilder::compileProcessor` lines 59-72). Verified in `BeforeInterceptorTenantWorkaroundTest`: a `#[Before(pointcut: AsynchronousRunningEndpoint::class, changeHeaders: true)]` interceptor with default precedence successfully sets the tenant header before multi-tenant Around fires. + +2. **Channel-adapter modules already propagate consumer-method annotations to the gateway.** `KafkaModule.php:151` and `RabbitConsumerModule.php:68` both call `->withEndpointAnnotations($annotatedMethod->getAllAnnotationDefinitions())` when registering the inbound adapter. So a method-level attribute placed alongside `#[KafkaConsumer]` / `#[RabbitConsumer]` reaches the gateway's endpoint annotations and becomes visible to interceptor pointcuts and parameter matching at compile time. + +(For `#[Asynchronous]` polling consumers — the internal flow — handler-method annotations do *not* propagate to the gateway. That's fine: those flows have an Ecotone producer where `#[AddHeader]` already works. This feature is for the inbound-broker case.) ## Solution -Introduce a method-level attribute `#[WithTenantResolver(expression: "...")]` and have the multi-tenant module automatically register a Before interceptor whose pointcut targets that attribute. Only handlers that opt in via the attribute pay any cost; for everyone else the chain is unchanged. +Introduce a method-level attribute `#[WithTenantResolver(expression: "...")]` placed on the same consumer method as `#[KafkaConsumer]` / `#[RabbitConsumer]`. The multi-tenant module registers a Before interceptor whose pointcut is the attribute itself, so it fires only on consumer methods that opt in. ### User-facing API ```php -final class OrderService +final class OrderEvents { - #[Asynchronous('orders_topic')] - #[CommandHandler('processExternalOrder')] + #[KafkaConsumer('orders_consumer', 'orders_topic')] #[WithTenantResolver(expression: "headers['kafka_topic']")] - public function process(string $payload, #[Headers] array $headers): void + public function process(string $payload, array $metadata): void { // tenant header is set from kafka_topic before multi-tenant switching fires } @@ -52,6 +55,8 @@ The expression has access to `payload` and `headers` (same context as `#[AddHead #[WithTenantResolver(expression: "reference('topicToTenantMapper').map(headers['kafka_topic'])")] ``` +Same idiom works for AMQP via `#[RabbitConsumer]` and any future broker module that follows the same `getAllAnnotationDefinitions()` propagation contract. + ### Components #### 1. Attribute — `Ecotone\Dbal\Attribute\WithTenantResolver` @@ -81,9 +86,9 @@ final class MultiTenantHeaderResolver private ExpressionEvaluationService $expressionEvaluationService, ) {} - public function resolve(Message $message, ?WithTenantResolver $resolverAttribute): array + public function resolve(Message $message, ?WithTenantResolver $config = null): array { - if ($resolverAttribute === null) { + if ($config === null) { return []; } if ($message->getHeaders()->containsKey($this->tenantHeaderName)) { @@ -91,7 +96,7 @@ final class MultiTenantHeaderResolver } $value = $this->expressionEvaluationService->evaluate( - $resolverAttribute->getExpression(), + $config->getExpression(), [ 'payload' => $message->getPayload(), 'headers' => $message->getHeaders()->headers(), @@ -103,7 +108,7 @@ final class MultiTenantHeaderResolver } ``` -The `?WithTenantResolver` parameter is the matched endpoint annotation, populated by the framework via the same mechanism `EndpointHeadersInterceptor::addMetadata` uses to receive `?AddHeader`. +The `?WithTenantResolver $config` parameter is matched at compile time from the gateway's endpoint annotations. The defensive `if ($config === null)` is there because the framework can pass null if pointcut and parameter matching diverge in edge cases; in normal operation the pointcut guarantees presence. #### 3. Wiring — `MultiTenantConnectionFactoryModule::prepare()` @@ -130,33 +135,36 @@ $messagingConfiguration->registerBeforeMethodInterceptor( ); ``` -The pointcut is `WithTenantResolver::class`, which means the interceptor only fires on methods carrying that attribute. Methods without it are unaffected. +The pointcut `WithTenantResolver::class` only matches gateways whose endpoint annotations include the attribute — i.e. consumer methods that opt in. Methods without it incur zero overhead (no interceptor evaluation, no pointcut match). ### Behaviour rules | Condition | Outcome | |---|---| -| Method has no `#[WithTenantResolver]` | Interceptor never fires. | -| Method has the attribute, message already has the tenant header | Skip — explicit headers win (preserves internal flows). | +| Consumer method has no `#[WithTenantResolver]` | Interceptor never fires. Zero overhead. | +| Method has the attribute, message already has the tenant header | Skip — explicit headers win (preserves any internal-flow case where producer set it). | | Expression returns `null` | Skip — let the existing "Lack of context" error surface so misconfigurations fail loudly. | | Expression throws | Propagate. User's expression bugs surface immediately. | +| Method is `#[Asynchronous]` (internal polling consumer), not a broker channel adapter | Pointcut won't match (handler annotations aren't on the gateway in this path). Use `#[AddHeader]` for internal flows. | ### Scope (what this design does *not* cover) -- **Multiple multi-tenant configurations:** if more than one `MultiTenantConfiguration` exists, each config registers its own resolver instance. They will all fire on a `WithTenantResolver`-tagged method, each writing its own tenant header name. v1 assumes the single-config setup that the existing module's lines 80-85 already privileges. If multi-config support becomes a requirement later, the attribute can grow an optional `reference:` field naming the target config. -- **Producer-side derivation:** the existing `#[AddHeader]` already covers internal flows. This design targets the inbound-async case only, and the pointcut reflects that. -- **Documenting the underlying `#[Before]` recipe (option A):** explicitly out of scope per the agreed direction. +- **`#[Asynchronous]` polling consumers fed by external producers:** would require either a framework change to `InterceptedPollingConsumerBuilder` (propagate handler annotations to the gateway) or a different mechanism. The user's reported case is the broker-channel-adapter path, which this design covers. +- **Multiple multi-tenant configurations:** if more than one `MultiTenantConfiguration` exists, each registers its own resolver instance with its own tenant header name; both fire on a `WithTenantResolver`-tagged method. v1 assumes the single-config setup that the existing module's lines 80-85 already privileges. If multi-config support becomes a requirement later, the attribute can grow an optional `reference:` field naming the target config. +- **Producer-side derivation:** `#[AddHeader]` already covers internal flows. This design targets the inbound-broker case only. ## Test strategy -- **Reproduction test** (already in repo): `ExternalMessageTenantMappingTest::test_externally_arriving_message_without_tenant_header_should_be_resolvable` — currently fails with "Lack of context about tenant"; should pass once the handler is annotated with `#[WithTenantResolver(expression: "headers['source_topic']")]`. -- **Workaround verification test** (already in repo): `BeforeInterceptorTenantWorkaroundTest` — passes today; documents that the underlying `#[Before]` mechanism is the foundation. Worth keeping as a regression check on the chain ordering it depends on. -- **New tests for the attribute:** - - Header is derived from a single source header (the happy path the user asked for). +- **Reproduction test** (already in repo): `ExternalMessageTenantMappingTest::test_externally_arriving_message_without_tenant_header_should_be_resolvable` — currently fails with "Lack of context about tenant". The CommandBus-based reproduction simulates the symptom (producer-less arrival in the queue) but doesn't exercise the broker-channel-adapter path. A new integration test on Kafka would cover the real path. +- **Workaround verification test** (already in repo): `BeforeInterceptorTenantWorkaroundTest` — passes today; documents the foundational mechanism. Worth keeping as a regression check on the chain ordering this design depends on. +- **New tests for the attribute (DBAL package, no broker dependency):** + - Attribute is recognised and routed correctly when present on a `#[KafkaConsumer]`-style fixture method (use a fake channel-adapter builder that mimics annotation propagation, similar to `FakeMessageChannelWithConnectionFactoryBuilder`). + - Header is derived from a single source header. - Service-backed expression via `reference('mapper').map(...)`. - Existing tenant header is preserved when present. - Expression returning `null` falls through to the existing error path. - - A method *without* `#[WithTenantResolver]` is unaffected (no interceptor invocation, no behaviour change). + - A consumer method *without* `#[WithTenantResolver]` is unaffected. +- **New integration test (Kafka package, optional v1 add):** end-to-end verification with a real Kafka topic — sends a message, consumer derives tenant from `kafka_topic`, multi-tenant switches connection. Marks the feature as proven on a real broker. ## Files touched @@ -165,3 +173,5 @@ The pointcut is `WithTenantResolver::class`, which means the interceptor only fi - `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` — register resolver and Before interceptor inside the existing per-config loop - `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/` — fixture handler(s) using the new attribute - `packages/Dbal/tests/Integration/MultiTenant/` — new test class covering the attribute behaviours; existing reproduction updated to use the attribute and assert success + +No core framework changes required. From 96a5661395387a16682b05624fc33328f97caf71 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 30 Apr 2026 14:15:36 +0200 Subject: [PATCH 03/13] docs: implementation plan for external-message tenant resolver Step-by-step TDD plan for the WithTenantResolver attribute design: attribute + resolver service + module wiring + behaviour tests covering explicit-header preservation, null-result fallthrough, reference()-based mapper, and unannotated-handler isolation. --- ...-04-30-external-message-tenant-resolver.md | 711 ++++++++++++++++++ 1 file changed, 711 insertions(+) create mode 100644 docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md diff --git a/docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md b/docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md new file mode 100644 index 000000000..5ad1c7d5b --- /dev/null +++ b/docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md @@ -0,0 +1,711 @@ +# External-Message Tenant Resolver Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `#[WithTenantResolver(expression: ...)]` method-level attribute that lets users derive the multi-tenant header from inbound message headers (e.g. `kafka_topic`) before multi-tenant connection switching fires, for messages arriving from external broker channel adapters. + +**Architecture:** A new Apache-licensed attribute in `Ecotone\Dbal\Attribute`, a new resolver service in `Ecotone\Dbal\MultiTenant`, and a single `registerBeforeMethodInterceptor` call inside the existing `MultiTenantConnectionFactoryModule::prepare()` per-config loop. Pointcut is the attribute itself, so the interceptor only fires on consumer methods that opt in. Channel-adapter modules (Kafka, AMQP) already propagate `getAllAnnotationDefinitions()` to the gateway — no core framework changes needed. + +**Tech Stack:** PHP 8.1+, Ecotone DBAL package, PHPUnit 11. Tests run inside docker compose `app` service. + +--- + +## File Structure + +| File | Responsibility | +|---|---| +| `packages/Dbal/src/Attribute/WithTenantResolver.php` (new) | Method-level attribute carrying the expression string | +| `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` (new) | Service whose `resolve()` method evaluates the expression and returns the tenant header | +| `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` (modify) | Wire the resolver service + Before interceptor inside the existing per-config loop | +| `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php` (modify) | Existing fixture; add a variant handler annotated with `#[WithTenantResolver]` | +| `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` (modify) | Existing reproduction; updated to assert the resolver derives the tenant header successfully | +| `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` (new) | Behaviour tests: existing-header preservation, null expression, service-backed mapper, no-attribute no-op | +| `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php` (new) | Tiny mapper service used by the `reference()`-based test | + +The existing `BeforeInterceptorTenantWorkaroundTest` and `TenantHeaderInterceptor.php` fixture stay as-is — they document the underlying mechanism and serve as a regression check. + +--- + +### Task 1: Create the `WithTenantResolver` attribute + +**Files:** +- Create: `packages/Dbal/src/Attribute/WithTenantResolver.php` + +- [ ] **Step 1: Create the attribute file** + +```php +expression; + } +} +``` + +- [ ] **Step 2: Verify the file is autoloadable** + +Run: `docker compose exec -T app php -r "require 'vendor/autoload.php'; echo class_exists(Ecotone\\Dbal\\Attribute\\WithTenantResolver::class) ? 'OK' : 'MISSING';"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add packages/Dbal/src/Attribute/WithTenantResolver.php +git commit -m "feat(dbal): add WithTenantResolver attribute" +``` + +--- + +### Task 2: Create the resolver service + +**Files:** +- Create: `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` + +- [ ] **Step 1: Create the resolver class** + +```php +getHeaders()->containsKey($this->tenantHeaderName)) { + return []; + } + + $value = $this->expressionEvaluationService->evaluate( + $config->getExpression(), + [ + 'payload' => $message->getPayload(), + 'headers' => $message->getHeaders()->headers(), + ] + ); + + return $value === null ? [] : [$this->tenantHeaderName => $value]; + } +} +``` + +- [ ] **Step 2: Verify the file is autoloadable** + +Run: `docker compose exec -T app php -r "require 'vendor/autoload.php'; echo class_exists(Ecotone\\Dbal\\MultiTenant\\MultiTenantHeaderResolver::class) ? 'OK' : 'MISSING';"` +Expected: `OK` + +- [ ] **Step 3: Commit** + +```bash +git add packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php +git commit -m "feat(dbal): add MultiTenantHeaderResolver service" +``` + +--- + +### Task 3: Wire the Before interceptor in the module + +**Files:** +- Modify: `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` (inside the existing `foreach ($multiTenantConfigurations as $multiTenantConfig)` loop, after the existing Around interceptor registration block ending at line 120) + +- [ ] **Step 1: Add the new imports at the top of the file** + +Add (alphabetised among the existing `use` block): + +```php +use Ecotone\Dbal\Attribute\WithTenantResolver; +use Ecotone\Dbal\MultiTenant\MultiTenantHeaderResolver; +use Ecotone\Messaging\Handler\ExpressionEvaluationService; +use Ecotone\Messaging\Handler\Processor\MethodInvoker\MethodInterceptorBuilder; +``` + +(`Definition`, `Reference`, `InterfaceToCallRegistry`, and `Precedence` are already imported.) + +- [ ] **Step 2: Append the resolver wiring inside the per-config loop** + +Inside the loop body, after the `registerAroundMethodInterceptor(...)` call that registers `propagateTenant` (the closing `);` is around line 120), insert: + +```php +$resolverReference = 'multi_tenant_header_resolver.' . $multiTenantConfig->getReferenceName(); +$messagingConfiguration->registerServiceDefinition( + $resolverReference, + new Definition( + MultiTenantHeaderResolver::class, + [ + $multiTenantConfig->getTenantHeaderName(), + Reference::to(ExpressionEvaluationService::REFERENCE), + ] + ) +); + +$messagingConfiguration->registerBeforeMethodInterceptor( + MethodInterceptorBuilder::create( + Reference::to($resolverReference), + $interfaceToCallRegistry->getFor(MultiTenantHeaderResolver::class, 'resolve'), + Precedence::DEFAULT_PRECEDENCE, + WithTenantResolver::class, + true + ) +); +``` + +- [ ] **Step 3: Run the existing multi-tenant test suite to confirm no regressions** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenantConnectionFactoryTest.php` +Expected: all tests pass (no errors, no failures). + +- [ ] **Step 4: Commit** + +```bash +git add packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php +git commit -m "feat(dbal): register WithTenantResolver Before interceptor" +``` + +--- + +### Task 4: Update reproduction fixture to use the attribute + +**Files:** +- Modify: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php` + +- [ ] **Step 1: Replace the file with the resolver-annotated variant** + +```php +receivedHeadersList[] = $headers; + } + + #[QueryHandler('lastReceivedHeaders')] + public function lastReceivedHeaders(): ?array + { + return array_shift($this->receivedHeadersList); + } +} +``` + +Note on rationale: this fixture uses `#[Asynchronous]` rather than a real `#[KafkaConsumer]` because the DBAL test suite must not depend on the Kafka package. The reproduction test verifies that the resolver's pointcut matches when the framework propagates the attribute to the chain — see Task 5 about why the existing reproduction test will still surface the timing bug if it currently does, and pass once the resolver fires. + +- [ ] **Step 2: Commit (kept separate from the reproduction-test edit so the fixture-only change is reviewable on its own)** + +```bash +git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php +git commit -m "test(dbal): annotate fixture handler with WithTenantResolver" +``` + +--- + +### Task 5: Flip reproduction test to assert resolved tenant + +**Files:** +- Modify: `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` + +The existing test should already fail today on `'Lack of context about tenant in Message Headers'`. With Tasks 1-4 in place, the resolver should fire, set the tenant header from `source_topic`, and the handler should run. The assertion already expects `tenant_a` — no body changes needed. + +- [ ] **Step 1: Run the reproduction test against the implementation** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` + +Expected (success path): test passes; no `Lack of context` error; received headers contain `'tenant' => 'tenant_a'`. + +- [ ] **Step 2: If the test still fails, diagnose the pointcut mismatch** + +If it fails with `Lack of context about tenant`, the pointcut isn't matching the polling-consumer gateway path used by `#[Asynchronous]`. This is the documented out-of-scope case in the spec. Two options: + +a) Convert the reproduction to use a real broker channel adapter (would require Kafka or AMQP test infrastructure — heavier, but exercises the actual user path). +b) Build a fake `ChannelAdapterConsumerBuilder` test fixture that propagates method annotations like the real broker modules do — keeps the DBAL test self-contained. + +Option (b) is preferred for v1 keeping DBAL tests broker-free. If this branch is reached, draft a follow-up task to add `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/FakeInboundChannelAdapterBuilder.php` mimicking `KafkaInboundChannelAdapterBuilder`'s annotation propagation, then update the reproduction test to use it. **Do not** silently change the assertion or weaken the test — surface the gap. + +- [ ] **Step 3: Commit (only if Step 1 succeeded; otherwise stop and discuss)** + +```bash +git add packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php +git commit -m "test(dbal): reproduction now passes via WithTenantResolver" +``` + +--- + +### Task 6: Add a topic-to-tenant mapper fixture + +**Files:** +- Create: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php` + +- [ ] **Step 1: Create the mapper class** + +```php + $mapping + */ + public function __construct(private array $mapping) + { + } + + public function map(string $sourceTopic): ?string + { + return $this->mapping[$sourceTopic] ?? null; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php +git commit -m "test(dbal): add TopicToTenantMapper fixture" +``` + +--- + +### Task 7: Test — existing tenant header is preserved + +**Files:** +- Create: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` + +- [ ] **Step 1: Write the file with the first test** + +```php +bootstrap($service); + + $ecotoneLite->sendCommandWithRoutingKey( + 'externalArrived', + 'hello', + metadata: ['tenant' => 'tenant_a', 'source_topic' => 'tenant_b'] + ); + + $ecotoneLite->run('external_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + + $headers = $ecotoneLite->sendQueryWithRouting('lastReceivedHeaders'); + + $this->assertNotNull($headers); + $this->assertSame('tenant_a', $headers['tenant'] ?? null, 'Explicit tenant header must win over resolver expression'); + } + + private function bootstrap(ExternalKafkaLikeService $service): \Ecotone\Lite\Test\FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + [ExternalKafkaLikeService::class], + [$service, 'tenant_a_connection' => new FakeConnectionFactory(), 'tenant_b_connection' => new FakeConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + PollingMetadata::create('external_topic')->setExecutionAmountLimit(1), + MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection', 'tenant_b' => 'tenant_b_connection'], DbalConnectionFactory::class), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('external_topic'), + ], + ); + } +} +``` + +- [ ] **Step 2: Run the test** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_existing_tenant_header_is_preserved_over_resolver_expression` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php +git commit -m "test(dbal): explicit tenant header wins over resolver" +``` + +--- + +### Task 8: Test — null expression result skips the header + +**Files:** +- Modify: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` + +- [ ] **Step 1: Append the test method (before the private `bootstrap` helper)** + +```php + public function test_resolver_returning_null_lets_existing_lack_of_context_error_surface(): void + { + $service = new ExternalKafkaLikeService(); + $ecotoneLite = $this->bootstrap($service); + + $ecotoneLite->sendCommandWithRoutingKey( + 'externalArrived', + 'hello', + metadata: [] + ); + + $this->expectException(\Ecotone\Messaging\Support\InvalidArgumentException::class); + $this->expectExceptionMessage('Lack of context about tenant'); + + $ecotoneLite->run('external_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + } +``` + +The handler's expression is `headers['source_topic']`; when no `source_topic` is sent, `evaluate()` returns `null` and the resolver no-ops. The downstream `ObjectManagerInterceptor` then surfaces the canonical `Lack of context about tenant` error. + +- [ ] **Step 2: Run the new test** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_resolver_returning_null_lets_existing_lack_of_context_error_surface` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php +git commit -m "test(dbal): null resolver result preserves existing error path" +``` + +--- + +### Task 9: Test — service-backed mapper via `reference()` + +**Files:** +- Modify: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` +- Create: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php` + +The existing fixture handler uses a literal `headers['source_topic']` expression. We need a separate handler whose attribute uses `reference('topicMapper').map(...)`. + +- [ ] **Step 1: Create the new fixture handler** + +Create `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php`: + +```php +receivedHeadersList[] = $headers; + } + + #[QueryHandler('mappedLastReceivedHeaders')] + public function lastReceivedHeaders(): ?array + { + return array_shift($this->receivedHeadersList); + } +} +``` + +- [ ] **Step 2: Append the test method to `WithTenantResolverTest.php`** + +```php + public function test_resolver_uses_service_backed_mapper_via_reference_expression(): void + { + $service = new \Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\MappedTenantService(); + $mapper = new \Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\TopicToTenantMapper([ + 'orders.us' => 'tenant_a', + 'orders.eu' => 'tenant_b', + ]); + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [\Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\MappedTenantService::class], + [ + $service, + 'topicMapper' => $mapper, + 'tenant_a_connection' => new FakeConnectionFactory(), + 'tenant_b_connection' => new FakeConnectionFactory(), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + PollingMetadata::create('mapped_topic')->setExecutionAmountLimit(1), + MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection', 'tenant_b' => 'tenant_b_connection'], DbalConnectionFactory::class), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('mapped_topic'), + ], + ); + + $ecotoneLite->sendCommandWithRoutingKey( + 'mappedArrived', + 'hello', + metadata: ['source_topic' => 'orders.eu'] + ); + + $ecotoneLite->run('mapped_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + + $headers = $ecotoneLite->sendQueryWithRouting('mappedLastReceivedHeaders'); + + $this->assertNotNull($headers); + $this->assertSame('tenant_b', $headers['tenant'] ?? null, 'Resolver must look up the tenant via the reference mapper'); + } +``` + +- [ ] **Step 3: Run the new test** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_resolver_uses_service_backed_mapper_via_reference_expression` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php +git commit -m "test(dbal): resolver supports reference()-based mapper expression" +``` + +--- + +### Task 10: Test — handler without the attribute is unaffected + +**Files:** +- Modify: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` +- Create: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php` + +- [ ] **Step 1: Create a fixture with no `WithTenantResolver`** + +Create `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php`: + +```php +receivedHeadersList[] = $headers; + } + + #[QueryHandler('plainLastReceivedHeaders')] + public function lastReceivedHeaders(): ?array + { + return array_shift($this->receivedHeadersList); + } +} +``` + +- [ ] **Step 2: Append the test** + +```php + public function test_handler_without_resolver_attribute_is_unaffected(): void + { + $service = new \Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\UnannotatedTenantService(); + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [\Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\UnannotatedTenantService::class], + [$service, 'tenant_a_connection' => new FakeConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + PollingMetadata::create('plain_topic')->setExecutionAmountLimit(1), + MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection'], DbalConnectionFactory::class), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('plain_topic'), + ], + ); + + $ecotoneLite->sendCommandWithRoutingKey( + 'plainArrived', + 'hello', + metadata: ['tenant' => 'tenant_a'] + ); + + $ecotoneLite->run('plain_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + + $headers = $ecotoneLite->sendQueryWithRouting('plainLastReceivedHeaders'); + + $this->assertNotNull($headers); + $this->assertSame('tenant_a', $headers['tenant'] ?? null); + } +``` + +- [ ] **Step 3: Run the test** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_handler_without_resolver_attribute_is_unaffected` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php +git commit -m "test(dbal): unannotated handler is unaffected by resolver" +``` + +--- + +### Task 11: Run the full multi-tenant test suite + +- [ ] **Step 1: Run every test that touches the multi-tenant module** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenantConnectionFactoryTest.php packages/Dbal/tests/Integration/MultiTenant/ packages/Dbal/tests/Integration/DbalBusinessMethod/MultiTenantTest.php` + +Expected: all tests pass (no errors, no failures). + +- [ ] **Step 2: If anything red, stop and diagnose** + +Do not proceed to the next task until everything in this scope is green. The most likely failure modes are: pointcut definition typo (`WithTenantResolver::class` vs full FQCN), wrong `MethodInterceptorBuilder::create` argument order, or a missing `use` import. + +- [ ] **Step 3: Run the broader DBAL test suite to catch unintended interactions** + +Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/` + +Expected: no new failures compared to the pre-feature baseline. (Existing failures unrelated to this branch — if any — should be unchanged.) + +--- + +### Task 12: Final tidy + +- [ ] **Step 1: Re-read the new files for stray TODOs, debug fwrites, or unused imports** + +Run: `git diff main..HEAD -- packages/Dbal/src packages/Dbal/tests | grep -E '(fwrite|var_dump|TODO|FIXME|XXX)' || echo 'clean'` +Expected: `clean`. + +- [ ] **Step 2: Confirm the diff against main matches the spec's "Files touched" section** + +Run: `git diff --stat main..HEAD` +Expected stat shows changes to (in addition to the spec doc already committed): +- `packages/Dbal/src/Attribute/WithTenantResolver.php` +- `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` +- `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` +- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php` +- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php` +- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php` +- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php` +- `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` +- `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` + +(Plus the existing `BeforeInterceptorTenantWorkaroundTest.php` and `TenantHeaderInterceptor.php` which were committed as part of the brainstorming phase.) From b4e3d815d1e27c9caff90ef7ac1e8b27a43e50a0 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Wed, 6 May 2026 21:19:03 +0200 Subject: [PATCH 04/13] feat: derive tenant header from inbound messages via #[WithTenantResolver] Adds a declarative #[WithTenantResolver(expression: "...")] attribute for inbound channel adapter methods (Kafka/AMQP/Scheduled). The resolver runs as a Before interceptor on the inbound gateway, evaluating the expression against payload+headers and injecting the configured tenant header into the message before any tenant-aware Around interceptor sees it. ScheduledModule now propagates handler-method annotations to its channel adapter gateway, mirroring KafkaModule/RabbitConsumerModule. Placement validation rejects #[WithTenantResolver] on synchronous/asynchronous handlers with an explanatory ConfigurationException. --- .../Dbal/src/Attribute/WithTenantResolver.php | 23 +++ .../MultiTenantConnectionFactoryModule.php | 58 +++++- .../MultiTenant/MultiTenantHeaderResolver.php | 56 ++++++ .../AsynchronousHandlerWithTenantResolver.php | 22 +++ .../CommandHandlerWithTenantResolver.php | 20 +++ .../EventHandlerWithTenantResolver.php | 21 +++ .../Scheduled/ExternalEventPoller.php | 43 +++++ ...ExternalEventPollerNonScalarExpression.php | 35 ++++ .../ExternalEventPollerNullExpression.php | 36 ++++ .../ExternalEventPollerWithoutResolver.php | 36 ++++ .../Scheduled/ExternalEventReceiver.php | 35 ++++ .../ScheduledTenantResolverTest.php | 170 ++++++++++++++++++ ...hTenantResolverPlacementValidationTest.php | 94 ++++++++++ .../ModuleConfiguration/ScheduledModule.php | 3 +- .../Scheduled/ScheduledMarkerAttribute.php | 18 ++ .../Scheduled/ScheduledServiceWithMarker.php | 20 +++ .../ScheduledModuleTest.php | 54 ++++++ packages/Kafka/composer.json | 4 +- .../MultiTenant/FakeConnectionFactoryStub.php | 20 +++ .../KafkaTenantConsumerExample.php | 58 ++++++ .../MultiTenant/KafkaTenantResolverTest.php | 117 ++++++++++++ 21 files changed, 940 insertions(+), 3 deletions(-) create mode 100644 packages/Dbal/src/Attribute/WithTenantResolver.php create mode 100644 packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/CommandHandlerWithTenantResolver.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/EventHandlerWithTenantResolver.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPoller.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php create mode 100644 packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php create mode 100644 packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php create mode 100644 packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php create mode 100644 packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php create mode 100644 packages/Ecotone/tests/Messaging/Unit/Config/Annotation/ModuleConfiguration/ScheduledModuleTest.php create mode 100644 packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php create mode 100644 packages/Kafka/tests/Fixture/MultiTenant/KafkaTenantConsumerExample.php create mode 100644 packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php diff --git a/packages/Dbal/src/Attribute/WithTenantResolver.php b/packages/Dbal/src/Attribute/WithTenantResolver.php new file mode 100644 index 000000000..e28145a64 --- /dev/null +++ b/packages/Dbal/src/Attribute/WithTenantResolver.php @@ -0,0 +1,23 @@ +expression; + } +} diff --git a/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php b/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php index f91c0383b..af9c3e7ac 100644 --- a/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php +++ b/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php @@ -5,10 +5,14 @@ namespace Ecotone\Dbal\MultiTenant\Module; use Ecotone\AnnotationFinder\AnnotationFinder; +use Ecotone\Dbal\Attribute\WithTenantResolver; use Ecotone\Dbal\MultiTenant\HeaderBasedMultiTenantConnectionFactory; use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Dbal\MultiTenant\MultiTenantConnectionFactory; +use Ecotone\Dbal\MultiTenant\MultiTenantHeaderResolver; use Ecotone\Messaging\Attribute\AsynchronousRunningEndpoint; +use Ecotone\Messaging\Attribute\ChannelAdapter; +use Ecotone\Messaging\Attribute\MessageConsumer; use Ecotone\Messaging\Attribute\MessageGateway; use Ecotone\Messaging\Attribute\ModuleAnnotation; use Ecotone\Messaging\Attribute\PropagateHeaders; @@ -18,11 +22,13 @@ use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ExtensionObjectResolver; use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\NoExternalConfigurationModule; use Ecotone\Messaging\Config\Configuration; +use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Config\Container\Definition; use Ecotone\Messaging\Config\Container\Reference; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ModuleReferenceSearchService; use Ecotone\Messaging\Gateway\MessagingEntrypointService; +use Ecotone\Messaging\Handler\ExpressionEvaluationService; use Ecotone\Messaging\Handler\InterfaceToCallRegistry; use Ecotone\Messaging\Handler\Logger\LoggingGateway; use Ecotone\Messaging\Handler\Processor\MethodInvoker\AroundInterceptorBuilder; @@ -40,13 +46,41 @@ */ final class MultiTenantConnectionFactoryModule extends NoExternalConfigurationModule implements AnnotationModule { + /** + * @param array $invalidTenantResolverPlacements + */ + private function __construct(private array $invalidTenantResolverPlacements) + { + } + public static function create(AnnotationFinder $annotationRegistrationService, InterfaceToCallRegistry $interfaceToCallRegistry): static { - return new self(); + $invalid = []; + foreach ($annotationRegistrationService->findAnnotatedMethods(WithTenantResolver::class) as $annotatedMethod) { + $isOnInboundAdapter = false; + foreach ($annotatedMethod->getMethodAnnotations() as $annotation) { + if ($annotation instanceof ChannelAdapter || $annotation instanceof MessageConsumer) { + $isOnInboundAdapter = true; + break; + } + } + if (! $isOnInboundAdapter) { + $invalid[] = $annotatedMethod->getClassName() . '::' . $annotatedMethod->getMethodName(); + } + } + + return new self($invalid); } public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void { + if ($this->invalidTenantResolverPlacements !== []) { + throw ConfigurationException::create(sprintf( + "WithTenantResolver attribute on %s is invalid. WithTenantResolver may only be applied to inbound channel adapter methods (e.g. #[KafkaConsumer], #[AmqpConsumer], #[Scheduled]) where messages may arrive from outside the application without a tenant header. Internal Message Channels — including those used by synchronous and asynchronous CommandHandler / EventHandler / QueryHandler / ServiceActivator handlers — already carry the tenant context propagated from the originating bus call, so there is no header to derive there. If an asynchronous handler is processing externally-arrived messages, attach #[WithTenantResolver] to the inbound channel adapter that produces those messages, not to the handler.", + implode(', ', $this->invalidTenantResolverPlacements) + )); + } + $messagingConfiguration->registerMessageChannel( SimpleMessageChannelBuilder::createPublishSubscribeChannel(HeaderBasedMultiTenantConnectionFactory::TENANT_ACTIVATED_CHANNEL_NAME) ); @@ -118,6 +152,28 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO MessageGateway::class ) ); + + $resolverReference = 'multi_tenant_header_resolver.' . $multiTenantConfig->getReferenceName(); + $messagingConfiguration->registerServiceDefinition( + $resolverReference, + new Definition( + MultiTenantHeaderResolver::class, + [ + $multiTenantConfig->getTenantHeaderName(), + Reference::to(ExpressionEvaluationService::REFERENCE), + ] + ) + ); + + $messagingConfiguration->registerBeforeMethodInterceptor( + MethodInterceptorBuilder::create( + Reference::to($resolverReference), + $interfaceToCallRegistry->getFor(MultiTenantHeaderResolver::class, 'resolve'), + Precedence::DEFAULT_PRECEDENCE, + WithTenantResolver::class, + true + ) + ); } } diff --git a/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php b/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php new file mode 100644 index 000000000..c77e57f3d --- /dev/null +++ b/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php @@ -0,0 +1,56 @@ +getHeaders()->containsKey($this->tenantHeaderName)) { + return []; + } + + $value = $this->expressionEvaluationService->evaluate( + $config->getExpression(), + [ + 'payload' => $message->getPayload(), + 'headers' => $message->getHeaders()->headers(), + ] + ); + + if ($value === null) { + return []; + } + + if (! is_string($value) && ! is_int($value)) { + $type = is_object($value) ? $value::class : gettype($value); + throw InvalidArgumentException::create(sprintf( + 'WithTenantResolver expression for tenant header "%s" must evaluate to string|int|null, got %s. Expression: %s', + $this->tenantHeaderName, + $type, + $config->getExpression() + )); + } + + return [$this->tenantHeaderName => $value]; + } +} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php b/packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php new file mode 100644 index 000000000..bd872e2cd --- /dev/null +++ b/packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php @@ -0,0 +1,22 @@ +}> */ + private array $pending; + + public function __construct(array $pending = []) + { + $this->pending = $pending; + } + + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + if ($this->pending === []) { + return null; + } + + $event = array_shift($this->pending); + $builder = MessageBuilder::withPayload($event['payload']) + ->setHeader('source', $event['source']); + + foreach ($event['additionalHeaders'] ?? [] as $name => $value) { + $builder = $builder->setHeader($name, $value); + } + + return $builder->build(); + } +} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php new file mode 100644 index 000000000..8b6494687 --- /dev/null +++ b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php @@ -0,0 +1,35 @@ +> */ + private array $pending; + + public function __construct(array $pending = []) + { + $this->pending = $pending; + } + + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: 'payload')] + public function poll(): ?Message + { + if ($this->pending === []) { + return null; + } + + return MessageBuilder::withPayload(array_shift($this->pending))->build(); + } +} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php new file mode 100644 index 000000000..546ac18c8 --- /dev/null +++ b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php @@ -0,0 +1,36 @@ + */ + private array $pending; + + public function __construct(array $pending = []) + { + $this->pending = $pending; + } + + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source'] ?? null")] + public function poll(): ?Message + { + if ($this->pending === []) { + return null; + } + + $event = array_shift($this->pending); + return MessageBuilder::withPayload($event['payload'])->build(); + } +} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php new file mode 100644 index 000000000..cc6c2f698 --- /dev/null +++ b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php @@ -0,0 +1,36 @@ + */ + private array $pending; + + public function __construct(array $pending = []) + { + $this->pending = $pending; + } + + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + public function poll(): ?Message + { + if ($this->pending === []) { + return null; + } + + $event = array_shift($this->pending); + return MessageBuilder::withPayload($event['payload']) + ->setHeader('source', $event['source']) + ->build(); + } +} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php new file mode 100644 index 000000000..44e6107d8 --- /dev/null +++ b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php @@ -0,0 +1,35 @@ +> */ + private array $captured = []; + + #[Asynchronous('external_processing')] + #[CommandHandler('externalEventArrived', endpointId: 'externalEventArrivedEndpoint')] + public function handle(mixed $payload, #[Headers] array $headers): void + { + $this->captured[] = $headers; + } + + /** + * @return array|null + */ + #[QueryHandler('lastCapturedHeaders')] + public function lastCapturedHeaders(): ?array + { + return array_shift($this->captured); + } +} diff --git a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php new file mode 100644 index 000000000..8c8dfdcaa --- /dev/null +++ b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php @@ -0,0 +1,170 @@ + 'tenant_a', 'payload' => 'first'], + ['source' => 'tenant_b', 'payload' => 'second'], + ]); + $receiver = new ExternalEventReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPoller::class, ExternalEventReceiver::class]); + + $this->pollOnce($ecotone); + $this->drainProcessing($ecotone); + + $first = $ecotone->sendQueryWithRouting('lastCapturedHeaders'); + $this->assertNotNull($first, 'Handler was never invoked - tenant resolution likely blocked the chain.'); + $this->assertSame('tenant_a', $first['tenant'] ?? null, 'Resolver should have derived tenant_a from headers[source].'); + + $this->pollOnce($ecotone); + $this->drainProcessing($ecotone); + + $second = $ecotone->sendQueryWithRouting('lastCapturedHeaders'); + $this->assertNotNull($second); + $this->assertSame('tenant_b', $second['tenant'] ?? null, 'Resolver should derive a fresh tenant per inbound message.'); + } + + public function test_explicit_tenant_header_takes_precedence_over_resolver(): void + { + $poller = new ExternalEventPoller([ + [ + 'source' => 'tenant_a', + 'payload' => 'first', + 'additionalHeaders' => ['tenant' => 'tenant_b'], + ], + ]); + $receiver = new ExternalEventReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPoller::class, ExternalEventReceiver::class]); + + $this->pollOnce($ecotone); + $this->drainProcessing($ecotone); + + $captured = $ecotone->sendQueryWithRouting('lastCapturedHeaders'); + $this->assertNotNull($captured); + $this->assertSame('tenant_b', $captured['tenant'] ?? null, 'Explicit tenant header must win over the resolver expression.'); + } + + public function test_no_tenant_header_when_resolver_attribute_missing(): void + { + $poller = new ExternalEventPollerWithoutResolver([ + ['source' => 'tenant_a', 'payload' => 'first'], + ]); + $receiver = new ExternalEventReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPollerWithoutResolver::class, ExternalEventReceiver::class]); + + $this->pollOnce($ecotone); + $this->drainProcessing($ecotone); + + $captured = $ecotone->sendQueryWithRouting('lastCapturedHeaders'); + $this->assertNotNull($captured); + $this->assertArrayNotHasKey('tenant', $captured, 'Without #[WithTenantResolver], no tenant header should be injected.'); + } + + public function test_no_tenant_header_when_expression_evaluates_to_null(): void + { + $poller = new ExternalEventPollerNullExpression([ + ['payload' => 'first'], + ]); + $receiver = new ExternalEventReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPollerNullExpression::class, ExternalEventReceiver::class]); + + $this->pollOnce($ecotone); + $this->drainProcessing($ecotone); + + $captured = $ecotone->sendQueryWithRouting('lastCapturedHeaders'); + $this->assertNotNull($captured); + $this->assertArrayNotHasKey('tenant', $captured, 'Null expression result must not inject any tenant header.'); + } + + public function test_throws_when_resolver_expression_returns_non_scalar(): void + { + $poller = new ExternalEventPollerNonScalarExpression([ + ['source' => 'tenant_a'], + ]); + $receiver = new ExternalEventReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPollerNonScalarExpression::class, ExternalEventReceiver::class]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('must evaluate to string|int|null'); + + $this->pollOnce($ecotone); + } + + /** + * @param object[] $services + * @param class-string[] $classes + */ + private function bootstrap(array $services, array $classes): FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + $classes, + array_merge($services, ['tenant_a_connection' => new FakeConnectionFactory()]), + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + PollingMetadata::create('externalEventPoller') + ->setExecutionAmountLimit(1) + ->setHandledMessageLimit(1), + PollingMetadata::create('external_processing') + ->setExecutionAmountLimit(1) + ->setHandledMessageLimit(1), + MultiTenantConfiguration::createWithDefaultConnection( + 'tenant', + ['tenant_a' => 'tenant_a_connection', 'tenant_b' => 'tenant_a_connection'], + 'tenant_a_connection', + DbalConnectionFactory::class, + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('external_processing'), + ], + ); + } + + private function pollOnce(FlowTestSupport $ecotone): void + { + $ecotone->run('externalEventPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + } + + private function drainProcessing(FlowTestSupport $ecotone): void + { + $ecotone->run('external_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + } +} diff --git a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php new file mode 100644 index 000000000..b5f1ee55a --- /dev/null +++ b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php @@ -0,0 +1,94 @@ +expectException(ConfigurationException::class); + $this->expectExceptionMessage(CommandHandlerWithTenantResolver::class . '::handle'); + $this->expectExceptionMessage('inbound channel adapter'); + $this->expectExceptionMessage('Internal Message Channels'); + + $this->bootstrap([CommandHandlerWithTenantResolver::class], [new CommandHandlerWithTenantResolver()]); + } + + public function test_throws_when_tenant_resolver_placed_on_event_handler(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(EventHandlerWithTenantResolver::class . '::on'); + + $this->bootstrap([EventHandlerWithTenantResolver::class], [new EventHandlerWithTenantResolver()]); + } + + public function test_throws_when_tenant_resolver_placed_on_asynchronous_handler(): void + { + $this->expectException(ConfigurationException::class); + $this->expectExceptionMessage(AsynchronousHandlerWithTenantResolver::class . '::handle'); + $this->expectExceptionMessage('inbound channel adapter'); + + $this->bootstrap([AsynchronousHandlerWithTenantResolver::class], [new AsynchronousHandlerWithTenantResolver()]); + } + + public function test_does_not_throw_when_tenant_resolver_placed_on_inbound_channel_adapter(): void + { + $ecotone = $this->bootstrap( + [ExternalEventPoller::class, ExternalEventReceiver::class], + [new ExternalEventPoller(), new ExternalEventReceiver()], + ); + + $this->assertNotNull($ecotone, 'Bootstrap should succeed when WithTenantResolver is placed on a #[Scheduled] inbound adapter.'); + } + + /** + * @param class-string[] $classes + * @param object[] $services + */ + private function bootstrap(array $classes, array $services): \Ecotone\Lite\Test\FlowTestSupport + { + return EcotoneLite::bootstrapFlowTesting( + $classes, + array_merge($services, ['tenant_a_connection' => new FakeConnectionFactory()]), + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + MultiTenantConfiguration::createWithDefaultConnection( + 'tenant', + ['tenant_a' => 'tenant_a_connection'], + 'tenant_a_connection', + DbalConnectionFactory::class, + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + ); + } +} diff --git a/packages/Ecotone/src/Messaging/Config/Annotation/ModuleConfiguration/ScheduledModule.php b/packages/Ecotone/src/Messaging/Config/Annotation/ModuleConfiguration/ScheduledModule.php index f990c98d4..1b3a0c1e7 100644 --- a/packages/Ecotone/src/Messaging/Config/Annotation/ModuleConfiguration/ScheduledModule.php +++ b/packages/Ecotone/src/Messaging/Config/Annotation/ModuleConfiguration/ScheduledModule.php @@ -41,7 +41,8 @@ public static function createConsumerFrom(AnnotatedFinding $annotationRegistrati $interfaceToCallRegistry->getFor($annotationRegistration->getClassName(), $annotationRegistration->getMethodName()) ) ->withEndpointId($annotation->getEndpointId()) - ->withRequiredInterceptorNames($annotation->getRequiredInterceptorNames()); + ->withRequiredInterceptorNames($annotation->getRequiredInterceptorNames()) + ->withEndpointAnnotations($annotationRegistration->getAllAnnotationDefinitions()); } public function getModulePackageName(): string diff --git a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php new file mode 100644 index 000000000..3fbb3e42e --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php @@ -0,0 +1,18 @@ +assertInstanceOf(InboundChannelAdapterBuilder::class, $builder); + + $endpointAttributeClassNames = array_map( + fn (AttributeDefinition $definition) => $definition->getClassName(), + $builder->getEndpointAnnotations() + ); + + $this->assertContains( + ScheduledMarkerAttribute::class, + $endpointAttributeClassNames, + 'Scheduled method attributes must reach the channel adapter gateway as endpoint annotations so attribute-pointcut interceptors can match them.' + ); + } +} diff --git a/packages/Kafka/composer.json b/packages/Kafka/composer.json index 639e1af43..2c5f04ebf 100644 --- a/packages/Kafka/composer.json +++ b/packages/Kafka/composer.json @@ -40,7 +40,9 @@ "phpstan/phpstan": "^1.8", "psr/container": "^1.1.1|^2.0.1", "wikimedia/composer-merge-plugin": "^2.1", - "kwn/php-rdkafka-stubs": "^2.2" + "kwn/php-rdkafka-stubs": "^2.2", + "ecotone/dbal": "~1.309.3", + "symfony/expression-language": "^6.4|^7.0|^8.0" }, "scripts": { "tests:phpstan": "vendor/bin/phpstan", diff --git a/packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php b/packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php new file mode 100644 index 000000000..8b5fe866e --- /dev/null +++ b/packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php @@ -0,0 +1,20 @@ +> */ + private array $captured = []; + + /** @param array $configuredTopics */ + public function __construct(private array $configuredTopics) + { + } + + #[KafkaConsumer('tenantTopicConsumer', topics: ['tenant_a_topic', 'tenant_b_topic'])] + #[WithTenantResolver(expression: "headers['kafka_topic']")] + public function handle(string $payload, #[Headers] array $headers): void + { + $this->captured[] = $headers; + } + + /** + * @return array|null + */ + #[QueryHandler('consumer.lastCapturedHeaders')] + public function lastCapturedHeaders(): ?array + { + return array_shift($this->captured); + } + + /** + * @return array> + */ + #[QueryHandler('consumer.allCapturedHeaders')] + public function allCapturedHeaders(): array + { + return $this->captured; + } + + /** + * @return array + */ + #[QueryHandler('consumer.configuredTopics')] + public function configuredTopics(): array + { + return $this->configuredTopics; + } +} diff --git a/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php b/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php new file mode 100644 index 000000000..b9910ff2d --- /dev/null +++ b/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php @@ -0,0 +1,117 @@ +toRfc4122(); + $tenantBTopic = 'tenant_b_' . Uuid::v7()->toRfc4122(); + + $consumer = new KafkaTenantConsumerExample([ + 'tenant_a_topic' => $tenantATopic, + 'tenant_b_topic' => $tenantBTopic, + ]); + + $ecotoneLite = EcotoneLite::bootstrapFlowTesting( + [KafkaTenantConsumerExample::class], + [ + $consumer, + KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection(), + 'tenant_a_connection' => new FakeConnectionFactoryStub(), + 'tenant_b_connection' => new FakeConnectionFactoryStub(), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::KAFKA_PACKAGE])) + ->withExtensionObjects([ + TopicConfiguration::createWithReferenceName('tenant_a_topic', $tenantATopic), + TopicConfiguration::createWithReferenceName('tenant_b_topic', $tenantBTopic), + MultiTenantConfiguration::create( + 'tenant', + [$tenantATopic => 'tenant_a_connection', $tenantBTopic => 'tenant_b_connection'], + DbalConnectionFactory::class, + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $this->publishToTopic($tenantATopic, 'payload_a'); + $this->publishToTopic($tenantBTopic, 'payload_b'); + + $ecotoneLite->run('tenantTopicConsumer', ExecutionPollingMetadata::createWithTestingSetup( + amountOfMessagesToHandle: 2, + maxExecutionTimeInMilliseconds: 30000, + )); + + $headersList = []; + while (($captured = $ecotoneLite->sendQueryWithRouting('consumer.lastCapturedHeaders')) !== null) { + $headersList[] = $captured; + } + + $this->assertCount(2, $headersList, 'Both Kafka messages should have been consumed.'); + + $byTenant = []; + foreach ($headersList as $headers) { + $this->assertArrayHasKey('tenant', $headers, 'Resolver should inject tenant header derived from kafka_topic.'); + $byTenant[$headers['tenant']] = $headers; + } + + $this->assertArrayHasKey($tenantATopic, $byTenant, 'Message from tenant_a topic should land with tenant=' . $tenantATopic); + $this->assertArrayHasKey($tenantBTopic, $byTenant, 'Message from tenant_b topic should land with tenant=' . $tenantBTopic); + $this->assertSame($tenantATopic, $byTenant[$tenantATopic]['kafka_topic'] ?? null, 'Resolved tenant must equal the originating kafka_topic header.'); + $this->assertSame($tenantBTopic, $byTenant[$tenantBTopic]['kafka_topic'] ?? null); + } + + private function publishToTopic(string $topic, string $payload): void + { + $brokerList = ConnectionTestCase::getConnection()->getBootstrapServers()[0]; + + $conf = new Conf(); + $conf->set('metadata.broker.list', $brokerList); + $conf->set('socket.timeout.ms', '50'); + $producer = new Producer($conf); + + $kafkaTopic = $producer->newTopic($topic); + $kafkaTopic->produce(RD_KAFKA_PARTITION_UA, 0, $payload); + $producer->poll(0); + + for ($i = 0; $i < 50 && $producer->getOutQLen() > 0; $i++) { + $producer->poll(50); + } + } +} From 95cb049b4d410cb614028b1b535896244c94a811 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 08:14:32 +0200 Subject: [PATCH 05/13] chore: stop tracking docs/superpowers planning artefacts These are local brainstorming and implementation-plan notes used during development, not user-facing documentation. Add the directory to .gitignore so they don't ship with PRs. --- .gitignore | 1 + ...-04-30-external-message-tenant-resolver.md | 711 ------------------ ...external-message-tenant-resolver-design.md | 177 ----- 3 files changed, 1 insertion(+), 888 deletions(-) delete mode 100644 docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md delete mode 100644 docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md diff --git a/.gitignore b/.gitignore index 394ebcb07..dd5205b0d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .idea/ +docs/superpowers/ vendor/ tests/coverage !tests/coverage/.gitkeep diff --git a/docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md b/docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md deleted file mode 100644 index 5ad1c7d5b..000000000 --- a/docs/superpowers/plans/2026-04-30-external-message-tenant-resolver.md +++ /dev/null @@ -1,711 +0,0 @@ -# External-Message Tenant Resolver Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a `#[WithTenantResolver(expression: ...)]` method-level attribute that lets users derive the multi-tenant header from inbound message headers (e.g. `kafka_topic`) before multi-tenant connection switching fires, for messages arriving from external broker channel adapters. - -**Architecture:** A new Apache-licensed attribute in `Ecotone\Dbal\Attribute`, a new resolver service in `Ecotone\Dbal\MultiTenant`, and a single `registerBeforeMethodInterceptor` call inside the existing `MultiTenantConnectionFactoryModule::prepare()` per-config loop. Pointcut is the attribute itself, so the interceptor only fires on consumer methods that opt in. Channel-adapter modules (Kafka, AMQP) already propagate `getAllAnnotationDefinitions()` to the gateway — no core framework changes needed. - -**Tech Stack:** PHP 8.1+, Ecotone DBAL package, PHPUnit 11. Tests run inside docker compose `app` service. - ---- - -## File Structure - -| File | Responsibility | -|---|---| -| `packages/Dbal/src/Attribute/WithTenantResolver.php` (new) | Method-level attribute carrying the expression string | -| `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` (new) | Service whose `resolve()` method evaluates the expression and returns the tenant header | -| `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` (modify) | Wire the resolver service + Before interceptor inside the existing per-config loop | -| `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php` (modify) | Existing fixture; add a variant handler annotated with `#[WithTenantResolver]` | -| `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` (modify) | Existing reproduction; updated to assert the resolver derives the tenant header successfully | -| `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` (new) | Behaviour tests: existing-header preservation, null expression, service-backed mapper, no-attribute no-op | -| `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php` (new) | Tiny mapper service used by the `reference()`-based test | - -The existing `BeforeInterceptorTenantWorkaroundTest` and `TenantHeaderInterceptor.php` fixture stay as-is — they document the underlying mechanism and serve as a regression check. - ---- - -### Task 1: Create the `WithTenantResolver` attribute - -**Files:** -- Create: `packages/Dbal/src/Attribute/WithTenantResolver.php` - -- [ ] **Step 1: Create the attribute file** - -```php -expression; - } -} -``` - -- [ ] **Step 2: Verify the file is autoloadable** - -Run: `docker compose exec -T app php -r "require 'vendor/autoload.php'; echo class_exists(Ecotone\\Dbal\\Attribute\\WithTenantResolver::class) ? 'OK' : 'MISSING';"` -Expected: `OK` - -- [ ] **Step 3: Commit** - -```bash -git add packages/Dbal/src/Attribute/WithTenantResolver.php -git commit -m "feat(dbal): add WithTenantResolver attribute" -``` - ---- - -### Task 2: Create the resolver service - -**Files:** -- Create: `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` - -- [ ] **Step 1: Create the resolver class** - -```php -getHeaders()->containsKey($this->tenantHeaderName)) { - return []; - } - - $value = $this->expressionEvaluationService->evaluate( - $config->getExpression(), - [ - 'payload' => $message->getPayload(), - 'headers' => $message->getHeaders()->headers(), - ] - ); - - return $value === null ? [] : [$this->tenantHeaderName => $value]; - } -} -``` - -- [ ] **Step 2: Verify the file is autoloadable** - -Run: `docker compose exec -T app php -r "require 'vendor/autoload.php'; echo class_exists(Ecotone\\Dbal\\MultiTenant\\MultiTenantHeaderResolver::class) ? 'OK' : 'MISSING';"` -Expected: `OK` - -- [ ] **Step 3: Commit** - -```bash -git add packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php -git commit -m "feat(dbal): add MultiTenantHeaderResolver service" -``` - ---- - -### Task 3: Wire the Before interceptor in the module - -**Files:** -- Modify: `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` (inside the existing `foreach ($multiTenantConfigurations as $multiTenantConfig)` loop, after the existing Around interceptor registration block ending at line 120) - -- [ ] **Step 1: Add the new imports at the top of the file** - -Add (alphabetised among the existing `use` block): - -```php -use Ecotone\Dbal\Attribute\WithTenantResolver; -use Ecotone\Dbal\MultiTenant\MultiTenantHeaderResolver; -use Ecotone\Messaging\Handler\ExpressionEvaluationService; -use Ecotone\Messaging\Handler\Processor\MethodInvoker\MethodInterceptorBuilder; -``` - -(`Definition`, `Reference`, `InterfaceToCallRegistry`, and `Precedence` are already imported.) - -- [ ] **Step 2: Append the resolver wiring inside the per-config loop** - -Inside the loop body, after the `registerAroundMethodInterceptor(...)` call that registers `propagateTenant` (the closing `);` is around line 120), insert: - -```php -$resolverReference = 'multi_tenant_header_resolver.' . $multiTenantConfig->getReferenceName(); -$messagingConfiguration->registerServiceDefinition( - $resolverReference, - new Definition( - MultiTenantHeaderResolver::class, - [ - $multiTenantConfig->getTenantHeaderName(), - Reference::to(ExpressionEvaluationService::REFERENCE), - ] - ) -); - -$messagingConfiguration->registerBeforeMethodInterceptor( - MethodInterceptorBuilder::create( - Reference::to($resolverReference), - $interfaceToCallRegistry->getFor(MultiTenantHeaderResolver::class, 'resolve'), - Precedence::DEFAULT_PRECEDENCE, - WithTenantResolver::class, - true - ) -); -``` - -- [ ] **Step 3: Run the existing multi-tenant test suite to confirm no regressions** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenantConnectionFactoryTest.php` -Expected: all tests pass (no errors, no failures). - -- [ ] **Step 4: Commit** - -```bash -git add packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php -git commit -m "feat(dbal): register WithTenantResolver Before interceptor" -``` - ---- - -### Task 4: Update reproduction fixture to use the attribute - -**Files:** -- Modify: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php` - -- [ ] **Step 1: Replace the file with the resolver-annotated variant** - -```php -receivedHeadersList[] = $headers; - } - - #[QueryHandler('lastReceivedHeaders')] - public function lastReceivedHeaders(): ?array - { - return array_shift($this->receivedHeadersList); - } -} -``` - -Note on rationale: this fixture uses `#[Asynchronous]` rather than a real `#[KafkaConsumer]` because the DBAL test suite must not depend on the Kafka package. The reproduction test verifies that the resolver's pointcut matches when the framework propagates the attribute to the chain — see Task 5 about why the existing reproduction test will still surface the timing bug if it currently does, and pass once the resolver fires. - -- [ ] **Step 2: Commit (kept separate from the reproduction-test edit so the fixture-only change is reviewable on its own)** - -```bash -git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php -git commit -m "test(dbal): annotate fixture handler with WithTenantResolver" -``` - ---- - -### Task 5: Flip reproduction test to assert resolved tenant - -**Files:** -- Modify: `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` - -The existing test should already fail today on `'Lack of context about tenant in Message Headers'`. With Tasks 1-4 in place, the resolver should fire, set the tenant header from `source_topic`, and the handler should run. The assertion already expects `tenant_a` — no body changes needed. - -- [ ] **Step 1: Run the reproduction test against the implementation** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` - -Expected (success path): test passes; no `Lack of context` error; received headers contain `'tenant' => 'tenant_a'`. - -- [ ] **Step 2: If the test still fails, diagnose the pointcut mismatch** - -If it fails with `Lack of context about tenant`, the pointcut isn't matching the polling-consumer gateway path used by `#[Asynchronous]`. This is the documented out-of-scope case in the spec. Two options: - -a) Convert the reproduction to use a real broker channel adapter (would require Kafka or AMQP test infrastructure — heavier, but exercises the actual user path). -b) Build a fake `ChannelAdapterConsumerBuilder` test fixture that propagates method annotations like the real broker modules do — keeps the DBAL test self-contained. - -Option (b) is preferred for v1 keeping DBAL tests broker-free. If this branch is reached, draft a follow-up task to add `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/FakeInboundChannelAdapterBuilder.php` mimicking `KafkaInboundChannelAdapterBuilder`'s annotation propagation, then update the reproduction test to use it. **Do not** silently change the assertion or weaken the test — surface the gap. - -- [ ] **Step 3: Commit (only if Step 1 succeeded; otherwise stop and discuss)** - -```bash -git add packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php -git commit -m "test(dbal): reproduction now passes via WithTenantResolver" -``` - ---- - -### Task 6: Add a topic-to-tenant mapper fixture - -**Files:** -- Create: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php` - -- [ ] **Step 1: Create the mapper class** - -```php - $mapping - */ - public function __construct(private array $mapping) - { - } - - public function map(string $sourceTopic): ?string - { - return $this->mapping[$sourceTopic] ?? null; - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php -git commit -m "test(dbal): add TopicToTenantMapper fixture" -``` - ---- - -### Task 7: Test — existing tenant header is preserved - -**Files:** -- Create: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` - -- [ ] **Step 1: Write the file with the first test** - -```php -bootstrap($service); - - $ecotoneLite->sendCommandWithRoutingKey( - 'externalArrived', - 'hello', - metadata: ['tenant' => 'tenant_a', 'source_topic' => 'tenant_b'] - ); - - $ecotoneLite->run('external_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - - $headers = $ecotoneLite->sendQueryWithRouting('lastReceivedHeaders'); - - $this->assertNotNull($headers); - $this->assertSame('tenant_a', $headers['tenant'] ?? null, 'Explicit tenant header must win over resolver expression'); - } - - private function bootstrap(ExternalKafkaLikeService $service): \Ecotone\Lite\Test\FlowTestSupport - { - return EcotoneLite::bootstrapFlowTesting( - [ExternalKafkaLikeService::class], - [$service, 'tenant_a_connection' => new FakeConnectionFactory(), 'tenant_b_connection' => new FakeConnectionFactory()], - ServiceConfiguration::createWithDefaults() - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) - ->withExtensionObjects([ - PollingMetadata::create('external_topic')->setExecutionAmountLimit(1), - MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection', 'tenant_b' => 'tenant_b_connection'], DbalConnectionFactory::class), - DbalConfiguration::createWithDefaults() - ->withTransactionOnCommandBus(false) - ->withTransactionOnAsynchronousEndpoints(false) - ->withClearAndFlushObjectManagerOnCommandBus(false) - ->withDeduplication(false), - ]), - enableAsynchronousProcessing: [ - SimpleMessageChannelBuilder::createQueueChannel('external_topic'), - ], - ); - } -} -``` - -- [ ] **Step 2: Run the test** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_existing_tenant_header_is_preserved_over_resolver_expression` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php -git commit -m "test(dbal): explicit tenant header wins over resolver" -``` - ---- - -### Task 8: Test — null expression result skips the header - -**Files:** -- Modify: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` - -- [ ] **Step 1: Append the test method (before the private `bootstrap` helper)** - -```php - public function test_resolver_returning_null_lets_existing_lack_of_context_error_surface(): void - { - $service = new ExternalKafkaLikeService(); - $ecotoneLite = $this->bootstrap($service); - - $ecotoneLite->sendCommandWithRoutingKey( - 'externalArrived', - 'hello', - metadata: [] - ); - - $this->expectException(\Ecotone\Messaging\Support\InvalidArgumentException::class); - $this->expectExceptionMessage('Lack of context about tenant'); - - $ecotoneLite->run('external_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - } -``` - -The handler's expression is `headers['source_topic']`; when no `source_topic` is sent, `evaluate()` returns `null` and the resolver no-ops. The downstream `ObjectManagerInterceptor` then surfaces the canonical `Lack of context about tenant` error. - -- [ ] **Step 2: Run the new test** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_resolver_returning_null_lets_existing_lack_of_context_error_surface` -Expected: PASS. - -- [ ] **Step 3: Commit** - -```bash -git add packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php -git commit -m "test(dbal): null resolver result preserves existing error path" -``` - ---- - -### Task 9: Test — service-backed mapper via `reference()` - -**Files:** -- Modify: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` -- Create: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php` - -The existing fixture handler uses a literal `headers['source_topic']` expression. We need a separate handler whose attribute uses `reference('topicMapper').map(...)`. - -- [ ] **Step 1: Create the new fixture handler** - -Create `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php`: - -```php -receivedHeadersList[] = $headers; - } - - #[QueryHandler('mappedLastReceivedHeaders')] - public function lastReceivedHeaders(): ?array - { - return array_shift($this->receivedHeadersList); - } -} -``` - -- [ ] **Step 2: Append the test method to `WithTenantResolverTest.php`** - -```php - public function test_resolver_uses_service_backed_mapper_via_reference_expression(): void - { - $service = new \Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\MappedTenantService(); - $mapper = new \Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\TopicToTenantMapper([ - 'orders.us' => 'tenant_a', - 'orders.eu' => 'tenant_b', - ]); - - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\MappedTenantService::class], - [ - $service, - 'topicMapper' => $mapper, - 'tenant_a_connection' => new FakeConnectionFactory(), - 'tenant_b_connection' => new FakeConnectionFactory(), - ], - ServiceConfiguration::createWithDefaults() - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) - ->withExtensionObjects([ - PollingMetadata::create('mapped_topic')->setExecutionAmountLimit(1), - MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection', 'tenant_b' => 'tenant_b_connection'], DbalConnectionFactory::class), - DbalConfiguration::createWithDefaults() - ->withTransactionOnCommandBus(false) - ->withTransactionOnAsynchronousEndpoints(false) - ->withClearAndFlushObjectManagerOnCommandBus(false) - ->withDeduplication(false), - ]), - enableAsynchronousProcessing: [ - SimpleMessageChannelBuilder::createQueueChannel('mapped_topic'), - ], - ); - - $ecotoneLite->sendCommandWithRoutingKey( - 'mappedArrived', - 'hello', - metadata: ['source_topic' => 'orders.eu'] - ); - - $ecotoneLite->run('mapped_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - - $headers = $ecotoneLite->sendQueryWithRouting('mappedLastReceivedHeaders'); - - $this->assertNotNull($headers); - $this->assertSame('tenant_b', $headers['tenant'] ?? null, 'Resolver must look up the tenant via the reference mapper'); - } -``` - -- [ ] **Step 3: Run the new test** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_resolver_uses_service_backed_mapper_via_reference_expression` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php -git commit -m "test(dbal): resolver supports reference()-based mapper expression" -``` - ---- - -### Task 10: Test — handler without the attribute is unaffected - -**Files:** -- Modify: `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` -- Create: `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php` - -- [ ] **Step 1: Create a fixture with no `WithTenantResolver`** - -Create `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php`: - -```php -receivedHeadersList[] = $headers; - } - - #[QueryHandler('plainLastReceivedHeaders')] - public function lastReceivedHeaders(): ?array - { - return array_shift($this->receivedHeadersList); - } -} -``` - -- [ ] **Step 2: Append the test** - -```php - public function test_handler_without_resolver_attribute_is_unaffected(): void - { - $service = new \Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\UnannotatedTenantService(); - - $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [\Test\Ecotone\Dbal\Fixture\MultiTenant\ExternalMessage\UnannotatedTenantService::class], - [$service, 'tenant_a_connection' => new FakeConnectionFactory()], - ServiceConfiguration::createWithDefaults() - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) - ->withExtensionObjects([ - PollingMetadata::create('plain_topic')->setExecutionAmountLimit(1), - MultiTenantConfiguration::create('tenant', ['tenant_a' => 'tenant_a_connection'], DbalConnectionFactory::class), - DbalConfiguration::createWithDefaults() - ->withTransactionOnCommandBus(false) - ->withTransactionOnAsynchronousEndpoints(false) - ->withClearAndFlushObjectManagerOnCommandBus(false) - ->withDeduplication(false), - ]), - enableAsynchronousProcessing: [ - SimpleMessageChannelBuilder::createQueueChannel('plain_topic'), - ], - ); - - $ecotoneLite->sendCommandWithRoutingKey( - 'plainArrived', - 'hello', - metadata: ['tenant' => 'tenant_a'] - ); - - $ecotoneLite->run('plain_topic', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - - $headers = $ecotoneLite->sendQueryWithRouting('plainLastReceivedHeaders'); - - $this->assertNotNull($headers); - $this->assertSame('tenant_a', $headers['tenant'] ?? null); - } -``` - -- [ ] **Step 3: Run the test** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php --filter test_handler_without_resolver_attribute_is_unaffected` -Expected: PASS. - -- [ ] **Step 4: Commit** - -```bash -git add packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php -git commit -m "test(dbal): unannotated handler is unaffected by resolver" -``` - ---- - -### Task 11: Run the full multi-tenant test suite - -- [ ] **Step 1: Run every test that touches the multi-tenant module** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/Integration/MultiTenantConnectionFactoryTest.php packages/Dbal/tests/Integration/MultiTenant/ packages/Dbal/tests/Integration/DbalBusinessMethod/MultiTenantTest.php` - -Expected: all tests pass (no errors, no failures). - -- [ ] **Step 2: If anything red, stop and diagnose** - -Do not proceed to the next task until everything in this scope is green. The most likely failure modes are: pointcut definition typo (`WithTenantResolver::class` vs full FQCN), wrong `MethodInterceptorBuilder::create` argument order, or a missing `use` import. - -- [ ] **Step 3: Run the broader DBAL test suite to catch unintended interactions** - -Run: `docker compose exec -T app vendor/bin/phpunit packages/Dbal/tests/` - -Expected: no new failures compared to the pre-feature baseline. (Existing failures unrelated to this branch — if any — should be unchanged.) - ---- - -### Task 12: Final tidy - -- [ ] **Step 1: Re-read the new files for stray TODOs, debug fwrites, or unused imports** - -Run: `git diff main..HEAD -- packages/Dbal/src packages/Dbal/tests | grep -E '(fwrite|var_dump|TODO|FIXME|XXX)' || echo 'clean'` -Expected: `clean`. - -- [ ] **Step 2: Confirm the diff against main matches the spec's "Files touched" section** - -Run: `git diff --stat main..HEAD` -Expected stat shows changes to (in addition to the spec doc already committed): -- `packages/Dbal/src/Attribute/WithTenantResolver.php` -- `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` -- `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` -- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/ExternalKafkaLikeService.php` -- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/MappedTenantService.php` -- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/TopicToTenantMapper.php` -- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/UnannotatedTenantService.php` -- `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php` -- `packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverTest.php` - -(Plus the existing `BeforeInterceptorTenantWorkaroundTest.php` and `TenantHeaderInterceptor.php` which were committed as part of the brainstorming phase.) diff --git a/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md b/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md deleted file mode 100644 index f6ccfad7d..000000000 --- a/docs/superpowers/specs/2026-04-30-external-message-tenant-resolver-design.md +++ /dev/null @@ -1,177 +0,0 @@ -# External-Message Tenant Resolver — Design - -**Date:** 2026-04-30 -**Status:** Draft, awaiting review -**Branch:** `feat/external-message-tenant-mapping` - -## Problem - -In a multi-tenant Ecotone application that consumes messages from an external (non-Ecotone) producer like Kafka, the inbound message envelope carries no `tenant` header — only headers that identify the source (e.g. `kafka_topic`). Users want to derive the tenant from one of those source headers so that the multi-tenant connection switching can pick the right database for the rest of the handler invocation. - -The natural attempt — `#[AddHeader('tenant', expression: "headers['kafka_topic']")]` on the handler — does not work for externally-arriving messages. The handler-level `#[AddHeader]` is registered as a *before-send* interceptor (`EndpointHeadersInterceptorModule`, precedence -3000) which fires when a producer-side gateway sends the message into the handler. For external messages, no producer-side gateway is involved: the message is polled from the broker and dispatched to the handler chain directly. The async dispatch chain then runs: - -``` -PollToGatewayTaskExecutor - → propagateTenant Around (-2001) ← reads tenant header, none found, proceeds - → CollectorSender Around - → ObjectManagerInterceptor (-1998) ← calls getConnectionFactory() - → throws "Lack of context about tenant in Message Headers" - (handler is never reached, so #[AddHeader] never runs) -``` - -A reproduction is in `packages/Dbal/tests/Integration/MultiTenant/ExternalMessageTenantMappingTest.php`. - -## Existing primitives we'll use - -Two pieces of the existing framework make this clean: - -1. **Before-method interceptors run before all Around interceptors at the same intercepted method**, regardless of precedence (`ChainedMessageProcessorBuilder::compileProcessor` lines 59-72). Verified in `BeforeInterceptorTenantWorkaroundTest`: a `#[Before(pointcut: AsynchronousRunningEndpoint::class, changeHeaders: true)]` interceptor with default precedence successfully sets the tenant header before multi-tenant Around fires. - -2. **Channel-adapter modules already propagate consumer-method annotations to the gateway.** `KafkaModule.php:151` and `RabbitConsumerModule.php:68` both call `->withEndpointAnnotations($annotatedMethod->getAllAnnotationDefinitions())` when registering the inbound adapter. So a method-level attribute placed alongside `#[KafkaConsumer]` / `#[RabbitConsumer]` reaches the gateway's endpoint annotations and becomes visible to interceptor pointcuts and parameter matching at compile time. - -(For `#[Asynchronous]` polling consumers — the internal flow — handler-method annotations do *not* propagate to the gateway. That's fine: those flows have an Ecotone producer where `#[AddHeader]` already works. This feature is for the inbound-broker case.) - -## Solution - -Introduce a method-level attribute `#[WithTenantResolver(expression: "...")]` placed on the same consumer method as `#[KafkaConsumer]` / `#[RabbitConsumer]`. The multi-tenant module registers a Before interceptor whose pointcut is the attribute itself, so it fires only on consumer methods that opt in. - -### User-facing API - -```php -final class OrderEvents -{ - #[KafkaConsumer('orders_consumer', 'orders_topic')] - #[WithTenantResolver(expression: "headers['kafka_topic']")] - public function process(string $payload, array $metadata): void - { - // tenant header is set from kafka_topic before multi-tenant switching fires - } -} -``` - -The expression has access to `payload` and `headers` (same context as `#[AddHeader]`). Service-backed mappers work via the existing expression-language `reference()` function: - -```php -#[WithTenantResolver(expression: "reference('topicToTenantMapper').map(headers['kafka_topic'])")] -``` - -Same idiom works for AMQP via `#[RabbitConsumer]` and any future broker module that follows the same `getAllAnnotationDefinitions()` propagation contract. - -### Components - -#### 1. Attribute — `Ecotone\Dbal\Attribute\WithTenantResolver` - -```php -#[Attribute(Attribute::TARGET_METHOD)] -final class WithTenantResolver -{ - public function __construct(public string $expression) {} - - public function getExpression(): string - { - return $this->expression; - } -} -``` - -Single field, single getter. No defaults — expression is required. - -#### 2. Resolver service — `Ecotone\Dbal\MultiTenant\MultiTenantHeaderResolver` - -```php -final class MultiTenantHeaderResolver -{ - public function __construct( - private string $tenantHeaderName, - private ExpressionEvaluationService $expressionEvaluationService, - ) {} - - public function resolve(Message $message, ?WithTenantResolver $config = null): array - { - if ($config === null) { - return []; - } - if ($message->getHeaders()->containsKey($this->tenantHeaderName)) { - return []; - } - - $value = $this->expressionEvaluationService->evaluate( - $config->getExpression(), - [ - 'payload' => $message->getPayload(), - 'headers' => $message->getHeaders()->headers(), - ] - ); - - return $value === null ? [] : [$this->tenantHeaderName => $value]; - } -} -``` - -The `?WithTenantResolver $config` parameter is matched at compile time from the gateway's endpoint annotations. The defensive `if ($config === null)` is there because the framework can pass null if pointcut and parameter matching diverge in edge cases; in normal operation the pointcut guarantees presence. - -#### 3. Wiring — `MultiTenantConnectionFactoryModule::prepare()` - -Inside the existing per-config loop, register the resolver service and a Before method interceptor whose pointcut is the new attribute: - -```php -$resolverReference = 'multi_tenant_header_resolver.' . $multiTenantConfig->getReferenceName(); -$messagingConfiguration->registerServiceDefinition( - $resolverReference, - new Definition(MultiTenantHeaderResolver::class, [ - $multiTenantConfig->getTenantHeaderName(), - Reference::to(ExpressionEvaluationService::REFERENCE), - ]) -); - -$messagingConfiguration->registerBeforeMethodInterceptor( - MethodInterceptorBuilder::create( - Reference::to($resolverReference), - $interfaceToCallRegistry->getFor(MultiTenantHeaderResolver::class, 'resolve'), - Precedence::DEFAULT_PRECEDENCE, - WithTenantResolver::class, - true - ) -); -``` - -The pointcut `WithTenantResolver::class` only matches gateways whose endpoint annotations include the attribute — i.e. consumer methods that opt in. Methods without it incur zero overhead (no interceptor evaluation, no pointcut match). - -### Behaviour rules - -| Condition | Outcome | -|---|---| -| Consumer method has no `#[WithTenantResolver]` | Interceptor never fires. Zero overhead. | -| Method has the attribute, message already has the tenant header | Skip — explicit headers win (preserves any internal-flow case where producer set it). | -| Expression returns `null` | Skip — let the existing "Lack of context" error surface so misconfigurations fail loudly. | -| Expression throws | Propagate. User's expression bugs surface immediately. | -| Method is `#[Asynchronous]` (internal polling consumer), not a broker channel adapter | Pointcut won't match (handler annotations aren't on the gateway in this path). Use `#[AddHeader]` for internal flows. | - -### Scope (what this design does *not* cover) - -- **`#[Asynchronous]` polling consumers fed by external producers:** would require either a framework change to `InterceptedPollingConsumerBuilder` (propagate handler annotations to the gateway) or a different mechanism. The user's reported case is the broker-channel-adapter path, which this design covers. -- **Multiple multi-tenant configurations:** if more than one `MultiTenantConfiguration` exists, each registers its own resolver instance with its own tenant header name; both fire on a `WithTenantResolver`-tagged method. v1 assumes the single-config setup that the existing module's lines 80-85 already privileges. If multi-config support becomes a requirement later, the attribute can grow an optional `reference:` field naming the target config. -- **Producer-side derivation:** `#[AddHeader]` already covers internal flows. This design targets the inbound-broker case only. - -## Test strategy - -- **Reproduction test** (already in repo): `ExternalMessageTenantMappingTest::test_externally_arriving_message_without_tenant_header_should_be_resolvable` — currently fails with "Lack of context about tenant". The CommandBus-based reproduction simulates the symptom (producer-less arrival in the queue) but doesn't exercise the broker-channel-adapter path. A new integration test on Kafka would cover the real path. -- **Workaround verification test** (already in repo): `BeforeInterceptorTenantWorkaroundTest` — passes today; documents the foundational mechanism. Worth keeping as a regression check on the chain ordering this design depends on. -- **New tests for the attribute (DBAL package, no broker dependency):** - - Attribute is recognised and routed correctly when present on a `#[KafkaConsumer]`-style fixture method (use a fake channel-adapter builder that mimics annotation propagation, similar to `FakeMessageChannelWithConnectionFactoryBuilder`). - - Header is derived from a single source header. - - Service-backed expression via `reference('mapper').map(...)`. - - Existing tenant header is preserved when present. - - Expression returning `null` falls through to the existing error path. - - A consumer method *without* `#[WithTenantResolver]` is unaffected. -- **New integration test (Kafka package, optional v1 add):** end-to-end verification with a real Kafka topic — sends a message, consumer derives tenant from `kafka_topic`, multi-tenant switches connection. Marks the feature as proven on a real broker. - -## Files touched - -- `packages/Dbal/src/Attribute/WithTenantResolver.php` — new -- `packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php` — new -- `packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php` — register resolver and Before interceptor inside the existing per-config loop -- `packages/Dbal/tests/Fixture/MultiTenant/ExternalMessage/` — fixture handler(s) using the new attribute -- `packages/Dbal/tests/Integration/MultiTenant/` — new test class covering the attribute behaviours; existing reproduction updated to use the attribute and assert success - -No core framework changes required. From c85c1054d4ff4a586da59eaada7d1463b5197c1e Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 08:18:30 +0200 Subject: [PATCH 06/13] test: assert WithTenantResolver fires exactly once per inbound message Counter-based regression guard: even though ScheduledModule now propagates handler annotations into the channel adapter's endpoint annotations while InboundChannelAdapterBuilder separately exposes the annotated interface, a single registered Before interceptor still produces exactly one match per target invocation. Pointcut matching is boolean. --- .../TenantResolverInvocationCounter.php | 29 +++++++++++++++++++ .../ScheduledTenantResolverTest.php | 23 +++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php new file mode 100644 index 000000000..7443135c7 --- /dev/null +++ b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php @@ -0,0 +1,29 @@ +count++; + } + + #[QueryHandler('counter.invocations')] + public function invocations(): int + { + return $this->count; + } +} diff --git a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php index 8c8dfdcaa..9ed35e3b4 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php @@ -22,6 +22,7 @@ use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPollerNullExpression; use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPollerWithoutResolver; use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventReceiver; +use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\TenantResolverInvocationCounter; /** * @internal @@ -108,6 +109,28 @@ public function test_no_tenant_header_when_expression_evaluates_to_null(): void $this->assertArrayNotHasKey('tenant', $captured, 'Null expression result must not inject any tenant header.'); } + public function test_resolver_interceptor_fires_exactly_once_per_inbound_message(): void + { + $poller = new ExternalEventPoller([ + ['source' => 'tenant_a', 'payload' => 'first'], + ]); + $receiver = new ExternalEventReceiver(); + $counter = new TenantResolverInvocationCounter(); + $ecotone = $this->bootstrap( + [$poller, $receiver, $counter], + [ExternalEventPoller::class, ExternalEventReceiver::class, TenantResolverInvocationCounter::class], + ); + + $this->pollOnce($ecotone); + $this->drainProcessing($ecotone); + + $this->assertSame( + 1, + $ecotone->sendQueryWithRouting('counter.invocations'), + 'Inbound channel adapter must trigger the WithTenantResolver Before interceptor exactly once per message; double-firing would mean propagating handler annotations into endpoint annotations is causing the same pointcut to match twice.' + ); + } + public function test_throws_when_resolver_expression_returns_non_scalar(): void { $poller = new ExternalEventPollerNonScalarExpression([ From d53646e726056f339bd2e07be8b88892a00cba54 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 08:22:51 +0200 Subject: [PATCH 07/13] test: rewrite ScheduledModuleTest at the EcotoneLite behavioural surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Earlier version asserted on InboundChannelAdapterBuilder::getEndpointAnnotations() directly — testing an internal storage primitive rather than user-observable behaviour. Replaced with a flow test that bootstraps EcotoneLite, runs the scheduled poller, and verifies a Before interceptor with a marker-attribute pointcut actually fires. This is what users rely on; how the framework delivers it internally is free to change. --- .../ScheduledMarkerInvocationCounter.php | 28 +++++++++++ .../Scheduled/ScheduledServiceWithMarker.php | 3 +- .../ScheduledModuleTest.php | 48 ++++++++----------- 3 files changed, 49 insertions(+), 30 deletions(-) create mode 100644 packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerInvocationCounter.php diff --git a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerInvocationCounter.php b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerInvocationCounter.php new file mode 100644 index 000000000..88620919a --- /dev/null +++ b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerInvocationCounter.php @@ -0,0 +1,28 @@ +count++; + } + + #[QueryHandler('scheduledMarker.count')] + public function count(): int + { + return $this->count; + } +} diff --git a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php index 8ff5bec37..df52c0eea 100644 --- a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php +++ b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php @@ -5,13 +5,14 @@ namespace Test\Ecotone\Messaging\Fixture\Scheduled; use Ecotone\Messaging\Attribute\Scheduled; +use Ecotone\Messaging\NullableMessageChannel; /** * licence Apache-2.0 */ final class ScheduledServiceWithMarker { - #[Scheduled(requestChannelName: 'scheduledTarget', endpointId: 'scheduledWithMarker')] + #[Scheduled(requestChannelName: NullableMessageChannel::CHANNEL_NAME, endpointId: 'scheduledWithMarker')] #[ScheduledMarkerAttribute('marked')] public function poll(): ?string { diff --git a/packages/Ecotone/tests/Messaging/Unit/Config/Annotation/ModuleConfiguration/ScheduledModuleTest.php b/packages/Ecotone/tests/Messaging/Unit/Config/Annotation/ModuleConfiguration/ScheduledModuleTest.php index 7478eee42..de78132d2 100644 --- a/packages/Ecotone/tests/Messaging/Unit/Config/Annotation/ModuleConfiguration/ScheduledModuleTest.php +++ b/packages/Ecotone/tests/Messaging/Unit/Config/Annotation/ModuleConfiguration/ScheduledModuleTest.php @@ -4,14 +4,12 @@ namespace Test\Ecotone\Messaging\Unit\Config\Annotation\ModuleConfiguration; -use Ecotone\AnnotationFinder\AnnotatedMethod; -use Ecotone\Messaging\Attribute\Scheduled; -use Ecotone\Messaging\Config\Annotation\ModuleConfiguration\ScheduledModule; -use Ecotone\Messaging\Config\Container\AttributeDefinition; -use Ecotone\Messaging\Endpoint\InboundChannelAdapter\InboundChannelAdapterBuilder; -use Ecotone\Messaging\Handler\InterfaceToCallRegistry; +use Ecotone\Lite\EcotoneLite; +use Ecotone\Messaging\Config\ModulePackageList; +use Ecotone\Messaging\Config\ServiceConfiguration; +use Ecotone\Messaging\Endpoint\ExecutionPollingMetadata; use PHPUnit\Framework\TestCase; -use Test\Ecotone\Messaging\Fixture\Scheduled\ScheduledMarkerAttribute; +use Test\Ecotone\Messaging\Fixture\Scheduled\ScheduledMarkerInvocationCounter; use Test\Ecotone\Messaging\Fixture\Scheduled\ScheduledServiceWithMarker; /** @@ -23,32 +21,24 @@ */ final class ScheduledModuleTest extends TestCase { - public function test_propagates_method_attributes_to_channel_adapter_endpoint_annotations(): void + public function test_attributes_on_scheduled_method_trigger_pointcut_interceptors(): void { - $scheduled = new Scheduled(requestChannelName: 'scheduledTarget', endpointId: 'scheduledWithMarker'); - $marker = new ScheduledMarkerAttribute('marked'); - - $annotatedMethod = AnnotatedMethod::create( - $scheduled, - ScheduledServiceWithMarker::class, - 'poll', - [], - [$scheduled, $marker] + $service = new ScheduledServiceWithMarker(); + $counter = new ScheduledMarkerInvocationCounter(); + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [ScheduledServiceWithMarker::class, ScheduledMarkerInvocationCounter::class], + [$service, $counter], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([])), ); - $builder = ScheduledModule::createConsumerFrom($annotatedMethod, InterfaceToCallRegistry::createEmpty()); - - $this->assertInstanceOf(InboundChannelAdapterBuilder::class, $builder); - - $endpointAttributeClassNames = array_map( - fn (AttributeDefinition $definition) => $definition->getClassName(), - $builder->getEndpointAnnotations() - ); + $ecotone->run('scheduledWithMarker', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - $this->assertContains( - ScheduledMarkerAttribute::class, - $endpointAttributeClassNames, - 'Scheduled method attributes must reach the channel adapter gateway as endpoint annotations so attribute-pointcut interceptors can match them.' + $this->assertSame( + 1, + $ecotone->sendQueryWithRouting('scheduledMarker.count'), + 'Method-level attributes on a #[Scheduled] method must reach the channel adapter so attribute-pointcut interceptors fire.', ); } } From bb2774119399b23c074c7ef4d43f2896623328d6 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 08:41:00 +0200 Subject: [PATCH 08/13] feat: gate WithTenantResolver behind Enterprise licence WithTenantResolver and MultiTenantHeaderResolver are now Enterprise-licensed. MultiTenantConnectionFactoryModule throws LicensingException at boot if any method is annotated with WithTenantResolver but no Enterprise licence is configured. New WithTenantResolverLicensingTest covers both branches. Existing tests updated to pass LicenceTesting::VALID_LICENCE. --- .../Dbal/src/Attribute/WithTenantResolver.php | 2 +- .../MultiTenantConnectionFactoryModule.php | 23 ++++- .../MultiTenant/MultiTenantHeaderResolver.php | 2 +- .../ScheduledTenantResolverTest.php | 2 + .../WithTenantResolverLicensingTest.php | 90 +++++++++++++++++++ ...hTenantResolverPlacementValidationTest.php | 2 + 6 files changed, 115 insertions(+), 6 deletions(-) create mode 100644 packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php diff --git a/packages/Dbal/src/Attribute/WithTenantResolver.php b/packages/Dbal/src/Attribute/WithTenantResolver.php index e28145a64..4fbda77a9 100644 --- a/packages/Dbal/src/Attribute/WithTenantResolver.php +++ b/packages/Dbal/src/Attribute/WithTenantResolver.php @@ -7,7 +7,7 @@ use Attribute; /** - * licence Apache-2.0 + * licence Enterprise */ #[Attribute(Attribute::TARGET_METHOD)] final class WithTenantResolver diff --git a/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php b/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php index af9c3e7ac..8e6266058 100644 --- a/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php +++ b/packages/Dbal/src/MultiTenant/Module/MultiTenantConnectionFactoryModule.php @@ -34,6 +34,7 @@ use Ecotone\Messaging\Handler\Processor\MethodInvoker\AroundInterceptorBuilder; use Ecotone\Messaging\Handler\Processor\MethodInvoker\MethodInterceptorBuilder; use Ecotone\Messaging\Precedence; +use Ecotone\Messaging\Support\LicensingException; use Ecotone\Modelling\CommandBus; use Ecotone\Modelling\EventBus; use Ecotone\Modelling\MessageHandling\MetadataPropagator\MessageHeadersPropagatorInterceptor; @@ -47,16 +48,23 @@ final class MultiTenantConnectionFactoryModule extends NoExternalConfigurationModule implements AnnotationModule { /** + * @param array $tenantResolverPlacements * @param array $invalidTenantResolverPlacements */ - private function __construct(private array $invalidTenantResolverPlacements) - { + private function __construct( + private array $tenantResolverPlacements, + private array $invalidTenantResolverPlacements, + ) { } public static function create(AnnotationFinder $annotationRegistrationService, InterfaceToCallRegistry $interfaceToCallRegistry): static { + $allPlacements = []; $invalid = []; foreach ($annotationRegistrationService->findAnnotatedMethods(WithTenantResolver::class) as $annotatedMethod) { + $location = $annotatedMethod->getClassName() . '::' . $annotatedMethod->getMethodName(); + $allPlacements[] = $location; + $isOnInboundAdapter = false; foreach ($annotatedMethod->getMethodAnnotations() as $annotation) { if ($annotation instanceof ChannelAdapter || $annotation instanceof MessageConsumer) { @@ -65,11 +73,11 @@ public static function create(AnnotationFinder $annotationRegistrationService, I } } if (! $isOnInboundAdapter) { - $invalid[] = $annotatedMethod->getClassName() . '::' . $annotatedMethod->getMethodName(); + $invalid[] = $location; } } - return new self($invalid); + return new self($allPlacements, $invalid); } public function prepare(Configuration $messagingConfiguration, array $extensionObjects, ModuleReferenceSearchService $moduleReferenceSearchService, InterfaceToCallRegistry $interfaceToCallRegistry): void @@ -81,6 +89,13 @@ public function prepare(Configuration $messagingConfiguration, array $extensionO )); } + if ($this->tenantResolverPlacements !== [] && ! $messagingConfiguration->isRunningForEnterpriseLicence()) { + throw LicensingException::create(sprintf( + 'WithTenantResolver attribute on %s requires Ecotone Enterprise licence.', + implode(', ', $this->tenantResolverPlacements) + )); + } + $messagingConfiguration->registerMessageChannel( SimpleMessageChannelBuilder::createPublishSubscribeChannel(HeaderBasedMultiTenantConnectionFactory::TENANT_ACTIVATED_CHANNEL_NAME) ); diff --git a/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php b/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php index c77e57f3d..87eb395f0 100644 --- a/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php +++ b/packages/Dbal/src/MultiTenant/MultiTenantHeaderResolver.php @@ -10,7 +10,7 @@ use Ecotone\Messaging\Support\InvalidArgumentException; /** - * licence Apache-2.0 + * licence Enterprise */ final class MultiTenantHeaderResolver { diff --git a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php index 9ed35e3b4..49991c4a9 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php @@ -14,6 +14,7 @@ use Ecotone\Messaging\Endpoint\ExecutionPollingMetadata; use Ecotone\Messaging\Endpoint\PollingMetadata; use Ecotone\Messaging\Support\InvalidArgumentException; +use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; use PHPUnit\Framework\TestCase; use Test\Ecotone\Dbal\Fixture\MultiTenant\FakeConnectionFactory; @@ -178,6 +179,7 @@ private function bootstrap(array $services, array $classes): FlowTestSupport enableAsynchronousProcessing: [ SimpleMessageChannelBuilder::createQueueChannel('external_processing'), ], + licenceKey: LicenceTesting::VALID_LICENCE, ); } diff --git a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php new file mode 100644 index 000000000..77d770581 --- /dev/null +++ b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php @@ -0,0 +1,90 @@ +expectException(LicensingException::class); + $this->expectExceptionMessage('WithTenantResolver'); + $this->expectExceptionMessage(ExternalEventPoller::class . '::poll'); + $this->expectExceptionMessage('Enterprise licence'); + + $this->bootstrapWithoutLicence(); + } + + public function test_bootstraps_successfully_with_enterprise_licence(): void + { + $this->bootstrapWithLicence(LicenceTesting::VALID_LICENCE); + + $this->assertTrue(true, 'Bootstrap with valid Enterprise licence must succeed when WithTenantResolver is in use.'); + } + + private function bootstrapWithoutLicence(): void + { + EcotoneLite::bootstrapFlowTesting( + [ExternalEventPoller::class, ExternalEventReceiver::class], + [new ExternalEventPoller(), new ExternalEventReceiver(), 'tenant_a_connection' => new FakeConnectionFactory()], + $this->serviceConfiguration(), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('external_processing'), + ], + ); + } + + private function bootstrapWithLicence(string $licenceKey): void + { + EcotoneLite::bootstrapFlowTesting( + [ExternalEventPoller::class, ExternalEventReceiver::class], + [new ExternalEventPoller(), new ExternalEventReceiver(), 'tenant_a_connection' => new FakeConnectionFactory()], + $this->serviceConfiguration(), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('external_processing'), + ], + licenceKey: $licenceKey, + ); + } + + private function serviceConfiguration(): ServiceConfiguration + { + return ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + MultiTenantConfiguration::createWithDefaultConnection( + 'tenant', + ['tenant_a' => 'tenant_a_connection'], + 'tenant_a_connection', + DbalConnectionFactory::class, + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]); + } +} diff --git a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php index b5f1ee55a..d3c52e9e2 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php @@ -10,6 +10,7 @@ use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; +use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; use PHPUnit\Framework\TestCase; use Test\Ecotone\Dbal\Fixture\MultiTenant\FakeConnectionFactory; @@ -89,6 +90,7 @@ private function bootstrap(array $classes, array $services): \Ecotone\Lite\Test\ ->withClearAndFlushObjectManagerOnCommandBus(false) ->withDeduplication(false), ]), + licenceKey: LicenceTesting::VALID_LICENCE, ); } } From 2d25835abf73c160093d2a178ebd5f7d7abe596e Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 09:06:31 +0200 Subject: [PATCH 09/13] test: inline tenant-resolver test fixtures as anonymous classes Each test now declares its services next to its assertions instead of loading them from packages/*/tests/Fixture/MultiTenant/. The anonymous classes are still picked up by EcotoneLite via \$instance::class, so behavior is unchanged. Reduces file count and makes scenarios easier to read end-to-end. --- .../AsynchronousHandlerWithTenantResolver.php | 22 -- .../CommandHandlerWithTenantResolver.php | 20 -- .../EventHandlerWithTenantResolver.php | 21 -- .../Scheduled/ExternalEventPoller.php | 43 ---- ...ExternalEventPollerNonScalarExpression.php | 35 ---- .../ExternalEventPollerNullExpression.php | 36 ---- .../ExternalEventPollerWithoutResolver.php | 36 ---- .../Scheduled/ExternalEventReceiver.php | 35 ---- .../TenantResolverInvocationCounter.php | 29 --- .../ScheduledTenantResolverTest.php | 194 ++++++++++++++---- .../WithTenantResolverLicensingTest.php | 73 ++++--- ...hTenantResolverPlacementValidationTest.php | 67 ++++-- .../Scheduled/ScheduledMarkerAttribute.php | 18 -- .../ScheduledMarkerInvocationCounter.php | 28 --- .../Scheduled/ScheduledServiceWithMarker.php | 21 -- .../ScheduledModuleTest.php | 43 +++- .../MultiTenant/FakeConnectionFactoryStub.php | 20 -- .../KafkaTenantConsumerExample.php | 58 ------ .../MultiTenant/KafkaTenantResolverTest.php | 46 ++++- 19 files changed, 316 insertions(+), 529 deletions(-) delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/CommandHandlerWithTenantResolver.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/EventHandlerWithTenantResolver.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPoller.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php delete mode 100644 packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php delete mode 100644 packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php delete mode 100644 packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerInvocationCounter.php delete mode 100644 packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php delete mode 100644 packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php delete mode 100644 packages/Kafka/tests/Fixture/MultiTenant/KafkaTenantConsumerExample.php diff --git a/packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php b/packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php deleted file mode 100644 index bd872e2cd..000000000 --- a/packages/Dbal/tests/Fixture/MultiTenant/InvalidPlacement/AsynchronousHandlerWithTenantResolver.php +++ /dev/null @@ -1,22 +0,0 @@ -}> */ - private array $pending; - - public function __construct(array $pending = []) - { - $this->pending = $pending; - } - - #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] - #[WithTenantResolver(expression: "headers['source']")] - public function poll(): ?Message - { - if ($this->pending === []) { - return null; - } - - $event = array_shift($this->pending); - $builder = MessageBuilder::withPayload($event['payload']) - ->setHeader('source', $event['source']); - - foreach ($event['additionalHeaders'] ?? [] as $name => $value) { - $builder = $builder->setHeader($name, $value); - } - - return $builder->build(); - } -} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php deleted file mode 100644 index 8b6494687..000000000 --- a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNonScalarExpression.php +++ /dev/null @@ -1,35 +0,0 @@ -> */ - private array $pending; - - public function __construct(array $pending = []) - { - $this->pending = $pending; - } - - #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] - #[WithTenantResolver(expression: 'payload')] - public function poll(): ?Message - { - if ($this->pending === []) { - return null; - } - - return MessageBuilder::withPayload(array_shift($this->pending))->build(); - } -} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php deleted file mode 100644 index 546ac18c8..000000000 --- a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerNullExpression.php +++ /dev/null @@ -1,36 +0,0 @@ - */ - private array $pending; - - public function __construct(array $pending = []) - { - $this->pending = $pending; - } - - #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] - #[WithTenantResolver(expression: "headers['source'] ?? null")] - public function poll(): ?Message - { - if ($this->pending === []) { - return null; - } - - $event = array_shift($this->pending); - return MessageBuilder::withPayload($event['payload'])->build(); - } -} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php deleted file mode 100644 index cc6c2f698..000000000 --- a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventPollerWithoutResolver.php +++ /dev/null @@ -1,36 +0,0 @@ - */ - private array $pending; - - public function __construct(array $pending = []) - { - $this->pending = $pending; - } - - #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] - public function poll(): ?Message - { - if ($this->pending === []) { - return null; - } - - $event = array_shift($this->pending); - return MessageBuilder::withPayload($event['payload']) - ->setHeader('source', $event['source']) - ->build(); - } -} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php deleted file mode 100644 index 44e6107d8..000000000 --- a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/ExternalEventReceiver.php +++ /dev/null @@ -1,35 +0,0 @@ -> */ - private array $captured = []; - - #[Asynchronous('external_processing')] - #[CommandHandler('externalEventArrived', endpointId: 'externalEventArrivedEndpoint')] - public function handle(mixed $payload, #[Headers] array $headers): void - { - $this->captured[] = $headers; - } - - /** - * @return array|null - */ - #[QueryHandler('lastCapturedHeaders')] - public function lastCapturedHeaders(): ?array - { - return array_shift($this->captured); - } -} diff --git a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php b/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php deleted file mode 100644 index 7443135c7..000000000 --- a/packages/Dbal/tests/Fixture/MultiTenant/Scheduled/TenantResolverInvocationCounter.php +++ /dev/null @@ -1,29 +0,0 @@ -count++; - } - - #[QueryHandler('counter.invocations')] - public function invocations(): int - { - return $this->count; - } -} diff --git a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php index 49991c4a9..e390b176a 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverTest.php @@ -4,26 +4,29 @@ namespace Test\Ecotone\Dbal\Integration\MultiTenant; +use Ecotone\Dbal\Attribute\WithTenantResolver; use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Lite\EcotoneLite; use Ecotone\Lite\Test\FlowTestSupport; +use Ecotone\Messaging\Attribute\Asynchronous; +use Ecotone\Messaging\Attribute\Interceptor\Before; +use Ecotone\Messaging\Attribute\Parameter\Headers; +use Ecotone\Messaging\Attribute\Scheduled; use Ecotone\Messaging\Channel\SimpleMessageChannelBuilder; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; use Ecotone\Messaging\Endpoint\ExecutionPollingMetadata; use Ecotone\Messaging\Endpoint\PollingMetadata; +use Ecotone\Messaging\Message; use Ecotone\Messaging\Support\InvalidArgumentException; +use Ecotone\Messaging\Support\MessageBuilder; +use Ecotone\Modelling\Attribute\CommandHandler; +use Ecotone\Modelling\Attribute\QueryHandler; use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; use PHPUnit\Framework\TestCase; use Test\Ecotone\Dbal\Fixture\MultiTenant\FakeConnectionFactory; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPoller; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPollerNonScalarExpression; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPollerNullExpression; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPollerWithoutResolver; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventReceiver; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\TenantResolverInvocationCounter; /** * @internal @@ -36,12 +39,29 @@ final class ScheduledTenantResolverTest extends TestCase { public function test_resolves_tenant_header_from_inbound_message_via_with_tenant_resolver(): void { - $poller = new ExternalEventPoller([ + $poller = new class ([ ['source' => 'tenant_a', 'payload' => 'first'], ['source' => 'tenant_b', 'payload' => 'second'], - ]); - $receiver = new ExternalEventReceiver(); - $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPoller::class, ExternalEventReceiver::class]); + ]) { + public function __construct(private array $pending) + { + } + + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + if ($this->pending === []) { + return null; + } + $event = array_shift($this->pending); + return MessageBuilder::withPayload($event['payload']) + ->setHeader('source', $event['source']) + ->build(); + } + }; + $receiver = $this->newReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [$poller::class, $receiver::class]); $this->pollOnce($ecotone); $this->drainProcessing($ecotone); @@ -60,15 +80,32 @@ public function test_resolves_tenant_header_from_inbound_message_via_with_tenant public function test_explicit_tenant_header_takes_precedence_over_resolver(): void { - $poller = new ExternalEventPoller([ - [ - 'source' => 'tenant_a', - 'payload' => 'first', - 'additionalHeaders' => ['tenant' => 'tenant_b'], - ], - ]); - $receiver = new ExternalEventReceiver(); - $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPoller::class, ExternalEventReceiver::class]); + $poller = new class ([ + 'source' => 'tenant_a', + 'payload' => 'first', + 'tenant' => 'tenant_b', + ]) { + public function __construct(private ?array $next) + { + } + + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + $event = $this->next; + $this->next = null; + if ($event === null) { + return null; + } + return MessageBuilder::withPayload($event['payload']) + ->setHeader('source', $event['source']) + ->setHeader('tenant', $event['tenant']) + ->build(); + } + }; + $receiver = $this->newReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [$poller::class, $receiver::class]); $this->pollOnce($ecotone); $this->drainProcessing($ecotone); @@ -80,11 +117,20 @@ public function test_explicit_tenant_header_takes_precedence_over_resolver(): vo public function test_no_tenant_header_when_resolver_attribute_missing(): void { - $poller = new ExternalEventPollerWithoutResolver([ - ['source' => 'tenant_a', 'payload' => 'first'], - ]); - $receiver = new ExternalEventReceiver(); - $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPollerWithoutResolver::class, ExternalEventReceiver::class]); + $poller = new class () { + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + public function poll(): ?Message + { + static $emitted = false; + if ($emitted) { + return null; + } + $emitted = true; + return MessageBuilder::withPayload('first')->setHeader('source', 'tenant_a')->build(); + } + }; + $receiver = $this->newReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [$poller::class, $receiver::class]); $this->pollOnce($ecotone); $this->drainProcessing($ecotone); @@ -96,11 +142,21 @@ public function test_no_tenant_header_when_resolver_attribute_missing(): void public function test_no_tenant_header_when_expression_evaluates_to_null(): void { - $poller = new ExternalEventPollerNullExpression([ - ['payload' => 'first'], - ]); - $receiver = new ExternalEventReceiver(); - $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPollerNullExpression::class, ExternalEventReceiver::class]); + $poller = new class () { + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source'] ?? null")] + public function poll(): ?Message + { + static $emitted = false; + if ($emitted) { + return null; + } + $emitted = true; + return MessageBuilder::withPayload('first')->build(); + } + }; + $receiver = $this->newReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [$poller::class, $receiver::class]); $this->pollOnce($ecotone); $this->drainProcessing($ecotone); @@ -112,14 +168,38 @@ public function test_no_tenant_header_when_expression_evaluates_to_null(): void public function test_resolver_interceptor_fires_exactly_once_per_inbound_message(): void { - $poller = new ExternalEventPoller([ - ['source' => 'tenant_a', 'payload' => 'first'], - ]); - $receiver = new ExternalEventReceiver(); - $counter = new TenantResolverInvocationCounter(); + $poller = new class () { + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + static $emitted = false; + if ($emitted) { + return null; + } + $emitted = true; + return MessageBuilder::withPayload('first')->setHeader('source', 'tenant_a')->build(); + } + }; + $receiver = $this->newReceiver(); + $counter = new class () { + private int $count = 0; + + #[Before(pointcut: WithTenantResolver::class)] + public function increment(): void + { + $this->count++; + } + + #[QueryHandler('counter.invocations')] + public function invocations(): int + { + return $this->count; + } + }; $ecotone = $this->bootstrap( [$poller, $receiver, $counter], - [ExternalEventPoller::class, ExternalEventReceiver::class, TenantResolverInvocationCounter::class], + [$poller::class, $receiver::class, $counter::class], ); $this->pollOnce($ecotone); @@ -134,11 +214,21 @@ public function test_resolver_interceptor_fires_exactly_once_per_inbound_message public function test_throws_when_resolver_expression_returns_non_scalar(): void { - $poller = new ExternalEventPollerNonScalarExpression([ - ['source' => 'tenant_a'], - ]); - $receiver = new ExternalEventReceiver(); - $ecotone = $this->bootstrap([$poller, $receiver], [ExternalEventPollerNonScalarExpression::class, ExternalEventReceiver::class]); + $poller = new class () { + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: 'payload')] + public function poll(): ?Message + { + static $emitted = false; + if ($emitted) { + return null; + } + $emitted = true; + return MessageBuilder::withPayload(['source' => 'tenant_a'])->build(); + } + }; + $receiver = $this->newReceiver(); + $ecotone = $this->bootstrap([$poller, $receiver], [$poller::class, $receiver::class]); $this->expectException(InvalidArgumentException::class); $this->expectExceptionMessage('must evaluate to string|int|null'); @@ -146,6 +236,30 @@ public function test_throws_when_resolver_expression_returns_non_scalar(): void $this->pollOnce($ecotone); } + private function newReceiver(): object + { + return new class () { + /** @var array> */ + private array $captured = []; + + #[Asynchronous('external_processing')] + #[CommandHandler('externalEventArrived', endpointId: 'externalEventArrivedEndpoint')] + public function handle(mixed $payload, #[Headers] array $headers): void + { + $this->captured[] = $headers; + } + + /** + * @return array|null + */ + #[QueryHandler('lastCapturedHeaders')] + public function lastCapturedHeaders(): ?array + { + return array_shift($this->captured); + } + }; + } + /** * @param object[] $services * @param class-string[] $classes diff --git a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php index 77d770581..0993062a9 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverLicensingTest.php @@ -4,19 +4,21 @@ namespace Test\Ecotone\Dbal\Integration\MultiTenant; +use Ecotone\Dbal\Attribute\WithTenantResolver; use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Lite\EcotoneLite; +use Ecotone\Messaging\Attribute\Scheduled; use Ecotone\Messaging\Channel\SimpleMessageChannelBuilder; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; +use Ecotone\Messaging\Message; use Ecotone\Messaging\Support\LicensingException; +use Ecotone\Messaging\Support\MessageBuilder; use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; use PHPUnit\Framework\TestCase; use Test\Ecotone\Dbal\Fixture\MultiTenant\FakeConnectionFactory; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPoller; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventReceiver; /** * @internal @@ -29,62 +31,59 @@ final class WithTenantResolverLicensingTest extends TestCase { public function test_throws_licensing_exception_when_no_enterprise_licence_provided(): void { + $service = $this->newTenantResolvingPoller(); + $this->expectException(LicensingException::class); $this->expectExceptionMessage('WithTenantResolver'); - $this->expectExceptionMessage(ExternalEventPoller::class . '::poll'); + $this->expectExceptionMessage($service::class . '::poll'); $this->expectExceptionMessage('Enterprise licence'); - $this->bootstrapWithoutLicence(); + $this->bootstrap($service, null); } public function test_bootstraps_successfully_with_enterprise_licence(): void { - $this->bootstrapWithLicence(LicenceTesting::VALID_LICENCE); + $this->bootstrap($this->newTenantResolvingPoller(), LicenceTesting::VALID_LICENCE); $this->assertTrue(true, 'Bootstrap with valid Enterprise licence must succeed when WithTenantResolver is in use.'); } - private function bootstrapWithoutLicence(): void + private function newTenantResolvingPoller(): object { - EcotoneLite::bootstrapFlowTesting( - [ExternalEventPoller::class, ExternalEventReceiver::class], - [new ExternalEventPoller(), new ExternalEventReceiver(), 'tenant_a_connection' => new FakeConnectionFactory()], - $this->serviceConfiguration(), - enableAsynchronousProcessing: [ - SimpleMessageChannelBuilder::createQueueChannel('external_processing'), - ], - ); + return new class () { + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + return MessageBuilder::withPayload('payload')->setHeader('source', 'tenant_a')->build(); + } + }; } - private function bootstrapWithLicence(string $licenceKey): void + private function bootstrap(object $service, ?string $licenceKey): void { EcotoneLite::bootstrapFlowTesting( - [ExternalEventPoller::class, ExternalEventReceiver::class], - [new ExternalEventPoller(), new ExternalEventReceiver(), 'tenant_a_connection' => new FakeConnectionFactory()], - $this->serviceConfiguration(), + [$service::class], + [$service, 'tenant_a_connection' => new FakeConnectionFactory()], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + MultiTenantConfiguration::createWithDefaultConnection( + 'tenant', + ['tenant_a' => 'tenant_a_connection'], + 'tenant_a_connection', + DbalConnectionFactory::class, + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), enableAsynchronousProcessing: [ SimpleMessageChannelBuilder::createQueueChannel('external_processing'), ], licenceKey: $licenceKey, ); } - - private function serviceConfiguration(): ServiceConfiguration - { - return ServiceConfiguration::createWithDefaults() - ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) - ->withExtensionObjects([ - MultiTenantConfiguration::createWithDefaultConnection( - 'tenant', - ['tenant_a' => 'tenant_a_connection'], - 'tenant_a_connection', - DbalConnectionFactory::class, - ), - DbalConfiguration::createWithDefaults() - ->withTransactionOnCommandBus(false) - ->withTransactionOnAsynchronousEndpoints(false) - ->withClearAndFlushObjectManagerOnCommandBus(false) - ->withDeduplication(false), - ]); - } } diff --git a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php index d3c52e9e2..f60122d55 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/WithTenantResolverPlacementValidationTest.php @@ -4,21 +4,25 @@ namespace Test\Ecotone\Dbal\Integration\MultiTenant; +use Ecotone\Dbal\Attribute\WithTenantResolver; use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Lite\EcotoneLite; +use Ecotone\Lite\Test\FlowTestSupport; +use Ecotone\Messaging\Attribute\Asynchronous; +use Ecotone\Messaging\Attribute\Scheduled; use Ecotone\Messaging\Config\ConfigurationException; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; +use Ecotone\Messaging\Message; +use Ecotone\Messaging\Support\MessageBuilder; +use Ecotone\Modelling\Attribute\CommandHandler; +use Ecotone\Modelling\Attribute\EventHandler; use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; use PHPUnit\Framework\TestCase; +use stdClass; use Test\Ecotone\Dbal\Fixture\MultiTenant\FakeConnectionFactory; -use Test\Ecotone\Dbal\Fixture\MultiTenant\InvalidPlacement\AsynchronousHandlerWithTenantResolver; -use Test\Ecotone\Dbal\Fixture\MultiTenant\InvalidPlacement\CommandHandlerWithTenantResolver; -use Test\Ecotone\Dbal\Fixture\MultiTenant\InvalidPlacement\EventHandlerWithTenantResolver; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventPoller; -use Test\Ecotone\Dbal\Fixture\MultiTenant\Scheduled\ExternalEventReceiver; /** * @internal @@ -31,37 +35,68 @@ final class WithTenantResolverPlacementValidationTest extends TestCase { public function test_throws_when_tenant_resolver_placed_on_synchronous_command_handler(): void { + $service = new class () { + #[CommandHandler('invalidPlacement')] + #[WithTenantResolver(expression: "headers['source']")] + public function handle(string $payload): void + { + } + }; + $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage(CommandHandlerWithTenantResolver::class . '::handle'); + $this->expectExceptionMessage($service::class . '::handle'); $this->expectExceptionMessage('inbound channel adapter'); $this->expectExceptionMessage('Internal Message Channels'); - $this->bootstrap([CommandHandlerWithTenantResolver::class], [new CommandHandlerWithTenantResolver()]); + $this->bootstrap([$service::class], [$service]); } public function test_throws_when_tenant_resolver_placed_on_event_handler(): void { + $service = new class () { + #[EventHandler] + #[WithTenantResolver(expression: "headers['source']")] + public function on(stdClass $event): void + { + } + }; + $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage(EventHandlerWithTenantResolver::class . '::on'); + $this->expectExceptionMessage($service::class . '::on'); - $this->bootstrap([EventHandlerWithTenantResolver::class], [new EventHandlerWithTenantResolver()]); + $this->bootstrap([$service::class], [$service]); } public function test_throws_when_tenant_resolver_placed_on_asynchronous_handler(): void { + $service = new class () { + #[Asynchronous('async_invalid_channel')] + #[CommandHandler('asyncInvalidPlacement', endpointId: 'asyncInvalidPlacementEndpoint')] + #[WithTenantResolver(expression: "headers['source']")] + public function handle(string $payload): void + { + } + }; + $this->expectException(ConfigurationException::class); - $this->expectExceptionMessage(AsynchronousHandlerWithTenantResolver::class . '::handle'); + $this->expectExceptionMessage($service::class . '::handle'); $this->expectExceptionMessage('inbound channel adapter'); - $this->bootstrap([AsynchronousHandlerWithTenantResolver::class], [new AsynchronousHandlerWithTenantResolver()]); + $this->bootstrap([$service::class], [$service]); } public function test_does_not_throw_when_tenant_resolver_placed_on_inbound_channel_adapter(): void { - $ecotone = $this->bootstrap( - [ExternalEventPoller::class, ExternalEventReceiver::class], - [new ExternalEventPoller(), new ExternalEventReceiver()], - ); + $service = new class () { + #[Scheduled(requestChannelName: 'externalEventArrived', endpointId: 'externalEventPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + return MessageBuilder::withPayload('payload')->setHeader('source', 'tenant_a')->build(); + } + }; + + $ecotone = $this->bootstrap([$service::class], [$service]); $this->assertNotNull($ecotone, 'Bootstrap should succeed when WithTenantResolver is placed on a #[Scheduled] inbound adapter.'); } @@ -70,7 +105,7 @@ public function test_does_not_throw_when_tenant_resolver_placed_on_inbound_chann * @param class-string[] $classes * @param object[] $services */ - private function bootstrap(array $classes, array $services): \Ecotone\Lite\Test\FlowTestSupport + private function bootstrap(array $classes, array $services): FlowTestSupport { return EcotoneLite::bootstrapFlowTesting( $classes, diff --git a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php deleted file mode 100644 index 3fbb3e42e..000000000 --- a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledMarkerAttribute.php +++ /dev/null @@ -1,18 +0,0 @@ -count++; - } - - #[QueryHandler('scheduledMarker.count')] - public function count(): int - { - return $this->count; - } -} diff --git a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php b/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php deleted file mode 100644 index df52c0eea..000000000 --- a/packages/Ecotone/tests/Messaging/Fixture/Scheduled/ScheduledServiceWithMarker.php +++ /dev/null @@ -1,21 +0,0 @@ -count++; + } + + #[QueryHandler('scheduledMarker.count')] + public function count(): int + { + return $this->count; + } + }; $ecotone = EcotoneLite::bootstrapFlowTesting( - [ScheduledServiceWithMarker::class, ScheduledMarkerInvocationCounter::class], + [$service::class, $counter::class], [$service, $counter], ServiceConfiguration::createWithDefaults() ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([])), diff --git a/packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php b/packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php deleted file mode 100644 index 8b5fe866e..000000000 --- a/packages/Kafka/tests/Fixture/MultiTenant/FakeConnectionFactoryStub.php +++ /dev/null @@ -1,20 +0,0 @@ -> */ - private array $captured = []; - - /** @param array $configuredTopics */ - public function __construct(private array $configuredTopics) - { - } - - #[KafkaConsumer('tenantTopicConsumer', topics: ['tenant_a_topic', 'tenant_b_topic'])] - #[WithTenantResolver(expression: "headers['kafka_topic']")] - public function handle(string $payload, #[Headers] array $headers): void - { - $this->captured[] = $headers; - } - - /** - * @return array|null - */ - #[QueryHandler('consumer.lastCapturedHeaders')] - public function lastCapturedHeaders(): ?array - { - return array_shift($this->captured); - } - - /** - * @return array> - */ - #[QueryHandler('consumer.allCapturedHeaders')] - public function allCapturedHeaders(): array - { - return $this->captured; - } - - /** - * @return array - */ - #[QueryHandler('consumer.configuredTopics')] - public function configuredTopics(): array - { - return $this->configuredTopics; - } -} diff --git a/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php b/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php index b9910ff2d..f2b126293 100644 --- a/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php +++ b/packages/Kafka/tests/Integration/MultiTenant/KafkaTenantResolverTest.php @@ -4,24 +4,29 @@ namespace Test\Ecotone\Kafka\Integration\MultiTenant; +use Ecotone\Dbal\Attribute\WithTenantResolver; use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; +use Ecotone\Kafka\Attribute\KafkaConsumer; use Ecotone\Kafka\Configuration\KafkaBrokerConfiguration; use Ecotone\Kafka\Configuration\TopicConfiguration; use Ecotone\Lite\EcotoneLite; +use Ecotone\Messaging\Attribute\Parameter\Headers; use Ecotone\Messaging\Config\ModulePackageList; use Ecotone\Messaging\Config\ServiceConfiguration; use Ecotone\Messaging\Endpoint\ExecutionPollingMetadata; +use Ecotone\Modelling\Attribute\QueryHandler; use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; +use Interop\Queue\ConnectionFactory; +use Interop\Queue\Context; +use LogicException; use PHPUnit\Framework\Attributes\RunTestsInSeparateProcesses; use PHPUnit\Framework\TestCase; use RdKafka\Conf; use RdKafka\Producer; use Symfony\Component\Uid\Uuid; use Test\Ecotone\Kafka\ConnectionTestCase; -use Test\Ecotone\Kafka\Fixture\MultiTenant\FakeConnectionFactoryStub; -use Test\Ecotone\Kafka\Fixture\MultiTenant\KafkaTenantConsumerExample; /** * @internal @@ -38,18 +43,41 @@ public function test_resolves_tenant_header_from_kafka_topic_for_inbound_message $tenantATopic = 'tenant_a_' . Uuid::v7()->toRfc4122(); $tenantBTopic = 'tenant_b_' . Uuid::v7()->toRfc4122(); - $consumer = new KafkaTenantConsumerExample([ - 'tenant_a_topic' => $tenantATopic, - 'tenant_b_topic' => $tenantBTopic, - ]); + $consumer = new class () { + /** @var array> */ + private array $captured = []; + + #[KafkaConsumer('tenantTopicConsumer', topics: ['tenant_a_topic', 'tenant_b_topic'])] + #[WithTenantResolver(expression: "headers['kafka_topic']")] + public function handle(string $payload, #[Headers] array $headers): void + { + $this->captured[] = $headers; + } + + /** + * @return array|null + */ + #[QueryHandler('consumer.lastCapturedHeaders')] + public function lastCapturedHeaders(): ?array + { + return array_shift($this->captured); + } + }; + + $stubConnection = new class () implements ConnectionFactory { + public function createContext(): Context + { + throw new LogicException('Tenant resolver test does not exercise downstream connection use.'); + } + }; $ecotoneLite = EcotoneLite::bootstrapFlowTesting( - [KafkaTenantConsumerExample::class], + [$consumer::class], [ $consumer, KafkaBrokerConfiguration::class => ConnectionTestCase::getConnection(), - 'tenant_a_connection' => new FakeConnectionFactoryStub(), - 'tenant_b_connection' => new FakeConnectionFactoryStub(), + 'tenant_a_connection' => $stubConnection, + 'tenant_b_connection' => $stubConnection, ], ServiceConfiguration::createWithDefaults() ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::KAFKA_PACKAGE])) From a4aca60945112c2a6d6555fa438282a6225ecfb7 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 09:32:36 +0200 Subject: [PATCH 10/13] test: real-DB integration test for tenant routing via WithTenantResolver Wires the Scheduled poller -> #[Asynchronous] CommandHandler flow against two real connections (postgres for tenant_a, mysql for tenant_b) and asserts that messages tagged source=tenant_a end up inserted in the postgres database while source=tenant_b inserts land in mysql. Builds on the DbalMessagingTestCase tenant connection factories already used by DbalBusinessMethod\MultiTenantTest. Proves the resolver-injected tenant header drives connection routing all the way through to the physical database, not just to header inspection in tests. --- ...duledTenantResolverDatabaseRoutingTest.php | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php diff --git a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php new file mode 100644 index 000000000..c2b3e9c04 --- /dev/null +++ b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php @@ -0,0 +1,151 @@ +connectionForTenantA()->createContext()->getDbalConnection(); + $tenantBDbal = $this->connectionForTenantB()->createContext()->getDbalConnection(); + $tenantADbal->executeStatement('DROP TABLE IF EXISTS persons'); + $tenantBDbal->executeStatement('DROP TABLE IF EXISTS persons'); + $this->setupUserTable($tenantADbal); + $this->setupUserTable($tenantBDbal); + + $poller = new class ([ + ['source' => 'tenant_a', 'personId' => 100, 'name' => 'Alice'], + ['source' => 'tenant_b', 'personId' => 200, 'name' => 'Bob'], + ]) { + public function __construct(private array $pending) + { + } + + #[Scheduled(requestChannelName: 'insertPerson', endpointId: 'externalPersonPoller')] + #[WithTenantResolver(expression: "headers['source']")] + public function poll(): ?Message + { + if ($this->pending === []) { + return null; + } + $event = array_shift($this->pending); + return MessageBuilder::withPayload($event['personId']) + ->setHeader('person_name', $event['name']) + ->setHeader('source', $event['source']) + ->build(); + } + }; + + $handler = new class () { + #[Asynchronous('persons_processing')] + #[CommandHandler('insertPerson', endpointId: 'insertPersonEndpoint')] + public function handle( + int $personId, + #[Header('person_name')] string $name, + #[Reference(DbalConnectionFactory::class)] ConnectionFactory $factory + ): void { + $factory->createContext()->getDbalConnection()->executeStatement( + 'INSERT INTO persons (person_id, name) VALUES (?, ?)', + [$personId, $name] + ); + } + }; + + $ecotone = EcotoneLite::bootstrapFlowTesting( + [$poller::class, $handler::class], + [ + $poller, + $handler, + 'tenant_a_connection' => $this->connectionForTenantA(), + 'tenant_b_connection' => $this->connectionForTenantB(), + ], + ServiceConfiguration::createWithDefaults() + ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) + ->withExtensionObjects([ + PollingMetadata::create('externalPersonPoller') + ->setExecutionAmountLimit(1) + ->setHandledMessageLimit(1), + PollingMetadata::create('persons_processing') + ->setExecutionAmountLimit(1) + ->setHandledMessageLimit(1), + MultiTenantConfiguration::create( + tenantHeaderName: 'tenant', + tenantToConnectionMapping: [ + 'tenant_a' => 'tenant_a_connection', + 'tenant_b' => 'tenant_b_connection', + ], + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]), + enableAsynchronousProcessing: [ + SimpleMessageChannelBuilder::createQueueChannel('persons_processing'), + ], + licenceKey: LicenceTesting::VALID_LICENCE, + ); + + $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('persons_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('persons_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + + $tenantARows = $this->fetchPersons($tenantADbal); + $tenantBRows = $this->fetchPersons($tenantBDbal); + + $this->assertSame( + [['person_id' => 100, 'name' => 'Alice']], + $tenantARows, + 'tenant_a database must contain only the tenant_a record. WithTenantResolver routes the inbound message via headers[source] -> tenant header -> tenant_a connection.' + ); + $this->assertSame( + [['person_id' => 200, 'name' => 'Bob']], + $tenantBRows, + 'tenant_b database must contain only the tenant_b record. Cross-tenant leakage would mean tenant routing failed.' + ); + } + + /** + * @return array + */ + private function fetchPersons(\Doctrine\DBAL\Connection $connection): array + { + $rows = $connection->fetchAllAssociative('SELECT person_id, name FROM persons ORDER BY person_id'); + return array_map( + fn (array $row): array => ['person_id' => (int) $row['person_id'], 'name' => (string) $row['name']], + $rows + ); + } +} From 57caf529df144b09571ccc2c6f1b218a21057fed Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 09:42:52 +0200 Subject: [PATCH 11/13] test: cover synchronous CommandHandler variant of tenant routing Splits ScheduledTenantResolverDatabaseRoutingTest into async and sync handler variants. Both bootstrap the same Scheduled poller + WithTenantResolver wiring against the two real tenant connections; the asynchronous variant routes through an in-memory queue and the synchronous variant lets the CommandHandler run inline. Either way, tenant_a and tenant_b inserts must land in the correct physical database. Message order swapped (tenant_b first) so the test would fail if routing accidentally relied on round-robin polling order instead of resolver-injected headers. --- ...duledTenantResolverDatabaseRoutingTest.php | 180 ++++++++++++------ 1 file changed, 120 insertions(+), 60 deletions(-) diff --git a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php index c2b3e9c04..6398e671c 100644 --- a/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php +++ b/packages/Dbal/tests/Integration/MultiTenant/ScheduledTenantResolverDatabaseRoutingTest.php @@ -4,12 +4,15 @@ namespace Test\Ecotone\Dbal\Integration\MultiTenant; +use Doctrine\DBAL\Connection; use Ecotone\Dbal\Attribute\WithTenantResolver; use Ecotone\Dbal\Configuration\DbalConfiguration; use Ecotone\Dbal\MultiTenant\MultiTenantConfiguration; use Ecotone\Lite\EcotoneLite; +use Ecotone\Lite\Test\FlowTestSupport; use Ecotone\Messaging\Attribute\Asynchronous; use Ecotone\Messaging\Attribute\Parameter\Header; +use Ecotone\Messaging\Attribute\Parameter\Reference; use Ecotone\Messaging\Attribute\Scheduled; use Ecotone\Messaging\Channel\SimpleMessageChannelBuilder; use Ecotone\Messaging\Config\ModulePackageList; @@ -18,7 +21,6 @@ use Ecotone\Messaging\Endpoint\PollingMetadata; use Ecotone\Messaging\Message; use Ecotone\Messaging\Support\MessageBuilder; -use Ecotone\Messaging\Attribute\Parameter\Reference; use Ecotone\Modelling\Attribute\CommandHandler; use Ecotone\Test\LicenceTesting; use Enqueue\Dbal\DbalConnectionFactory; @@ -34,7 +36,69 @@ */ final class ScheduledTenantResolverDatabaseRoutingTest extends DbalMessagingTestCase { - public function test_inserts_routed_to_per_tenant_database_via_resolved_tenant_header(): void + public function test_inserts_routed_to_per_tenant_database_when_handler_is_asynchronous(): void + { + [$tenantADbal, $tenantBDbal] = $this->resetTenantTables(); + + $poller = $this->newPoller(); + + $handler = new class () { + #[Asynchronous('persons_processing')] + #[CommandHandler('insertPerson', endpointId: 'insertPersonEndpoint')] + public function handle( + int $personId, + #[Header('person_name')] string $name, + #[Reference(DbalConnectionFactory::class)] ConnectionFactory $factory, + ): void { + $factory->createContext()->getDbalConnection()->executeStatement( + 'INSERT INTO persons (person_id, name) VALUES (?, ?)', + [$personId, $name] + ); + } + }; + + $ecotone = $this->bootstrap([$poller, $handler], asynchronous: true); + + $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('persons_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('persons_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + + $this->assertTenantTablesIsolated($tenantADbal, $tenantBDbal); + } + + public function test_inserts_routed_to_per_tenant_database_when_handler_is_synchronous(): void + { + [$tenantADbal, $tenantBDbal] = $this->resetTenantTables(); + + $poller = $this->newPoller(); + + $handler = new class () { + #[CommandHandler('insertPerson', endpointId: 'insertPersonEndpoint')] + public function handle( + int $personId, + #[Header('person_name')] string $name, + #[Reference(DbalConnectionFactory::class)] ConnectionFactory $factory, + ): void { + $factory->createContext()->getDbalConnection()->executeStatement( + 'INSERT INTO persons (person_id, name) VALUES (?, ?)', + [$personId, $name] + ); + } + }; + + $ecotone = $this->bootstrap([$poller, $handler], asynchronous: false); + + $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); + + $this->assertTenantTablesIsolated($tenantADbal, $tenantBDbal); + } + + /** + * @return array{0: Connection, 1: Connection} + */ + private function resetTenantTables(): array { $tenantADbal = $this->connectionForTenantA()->createContext()->getDbalConnection(); $tenantBDbal = $this->connectionForTenantB()->createContext()->getDbalConnection(); @@ -43,9 +107,14 @@ public function test_inserts_routed_to_per_tenant_database_via_resolved_tenant_h $this->setupUserTable($tenantADbal); $this->setupUserTable($tenantBDbal); - $poller = new class ([ - ['source' => 'tenant_a', 'personId' => 100, 'name' => 'Alice'], + return [$tenantADbal, $tenantBDbal]; + } + + private function newPoller(): object + { + return new class ([ ['source' => 'tenant_b', 'personId' => 200, 'name' => 'Bob'], + ['source' => 'tenant_a', 'personId' => 100, 'name' => 'Alice'], ]) { public function __construct(private array $pending) { @@ -65,74 +134,65 @@ public function poll(): ?Message ->build(); } }; + } - $handler = new class () { - #[Asynchronous('persons_processing')] - #[CommandHandler('insertPerson', endpointId: 'insertPersonEndpoint')] - public function handle( - int $personId, - #[Header('person_name')] string $name, - #[Reference(DbalConnectionFactory::class)] ConnectionFactory $factory - ): void { - $factory->createContext()->getDbalConnection()->executeStatement( - 'INSERT INTO persons (person_id, name) VALUES (?, ?)', - [$personId, $name] - ); - } - }; + /** + * @param object[] $services + */ + private function bootstrap(array $services, bool $asynchronous): FlowTestSupport + { + $extensionObjects = [ + PollingMetadata::create('externalPersonPoller') + ->setExecutionAmountLimit(1) + ->setHandledMessageLimit(1), + MultiTenantConfiguration::create( + tenantHeaderName: 'tenant', + tenantToConnectionMapping: [ + 'tenant_a' => 'tenant_a_connection', + 'tenant_b' => 'tenant_b_connection', + ], + ), + DbalConfiguration::createWithDefaults() + ->withTransactionOnCommandBus(false) + ->withTransactionOnAsynchronousEndpoints(false) + ->withClearAndFlushObjectManagerOnCommandBus(false) + ->withDeduplication(false), + ]; + if ($asynchronous) { + $extensionObjects[] = PollingMetadata::create('persons_processing') + ->setExecutionAmountLimit(1) + ->setHandledMessageLimit(1); + } - $ecotone = EcotoneLite::bootstrapFlowTesting( - [$poller::class, $handler::class], - [ - $poller, - $handler, - 'tenant_a_connection' => $this->connectionForTenantA(), - 'tenant_b_connection' => $this->connectionForTenantB(), - ], + return EcotoneLite::bootstrapFlowTesting( + array_map(static fn (object $service): string => $service::class, $services), + array_merge( + $services, + [ + 'tenant_a_connection' => $this->connectionForTenantA(), + 'tenant_b_connection' => $this->connectionForTenantB(), + ], + ), ServiceConfiguration::createWithDefaults() ->withSkippedModulePackageNames(ModulePackageList::allPackagesExcept([ModulePackageList::DBAL_PACKAGE, ModulePackageList::ASYNCHRONOUS_PACKAGE])) - ->withExtensionObjects([ - PollingMetadata::create('externalPersonPoller') - ->setExecutionAmountLimit(1) - ->setHandledMessageLimit(1), - PollingMetadata::create('persons_processing') - ->setExecutionAmountLimit(1) - ->setHandledMessageLimit(1), - MultiTenantConfiguration::create( - tenantHeaderName: 'tenant', - tenantToConnectionMapping: [ - 'tenant_a' => 'tenant_a_connection', - 'tenant_b' => 'tenant_b_connection', - ], - ), - DbalConfiguration::createWithDefaults() - ->withTransactionOnCommandBus(false) - ->withTransactionOnAsynchronousEndpoints(false) - ->withClearAndFlushObjectManagerOnCommandBus(false) - ->withDeduplication(false), - ]), - enableAsynchronousProcessing: [ - SimpleMessageChannelBuilder::createQueueChannel('persons_processing'), - ], + ->withExtensionObjects($extensionObjects), + enableAsynchronousProcessing: $asynchronous + ? [SimpleMessageChannelBuilder::createQueueChannel('persons_processing')] + : true, licenceKey: LicenceTesting::VALID_LICENCE, ); + } - $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - $ecotone->run('persons_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - $ecotone->run('externalPersonPoller', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - $ecotone->run('persons_processing', ExecutionPollingMetadata::createWithTestingSetup(1, 1)); - - $tenantARows = $this->fetchPersons($tenantADbal); - $tenantBRows = $this->fetchPersons($tenantBDbal); - + private function assertTenantTablesIsolated(Connection $tenantADbal, Connection $tenantBDbal): void + { $this->assertSame( [['person_id' => 100, 'name' => 'Alice']], - $tenantARows, + $this->fetchPersons($tenantADbal), 'tenant_a database must contain only the tenant_a record. WithTenantResolver routes the inbound message via headers[source] -> tenant header -> tenant_a connection.' ); $this->assertSame( [['person_id' => 200, 'name' => 'Bob']], - $tenantBRows, + $this->fetchPersons($tenantBDbal), 'tenant_b database must contain only the tenant_b record. Cross-tenant leakage would mean tenant routing failed.' ); } @@ -140,7 +200,7 @@ public function handle( /** * @return array */ - private function fetchPersons(\Doctrine\DBAL\Connection $connection): array + private function fetchPersons(Connection $connection): array { $rows = $connection->fetchAllAssociative('SELECT person_id, name FROM persons ORDER BY person_id'); return array_map( From 705578f7dbd22e7daf2d7d3f0fadd6d3a75c4143 Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 18:07:06 +0200 Subject: [PATCH 12/13] fixes --- packages/Kafka/composer.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/Kafka/composer.json b/packages/Kafka/composer.json index f988dc2c8..26eb3ff62 100644 --- a/packages/Kafka/composer.json +++ b/packages/Kafka/composer.json @@ -48,7 +48,6 @@ "phpunit/phpunit": "^10.5|^11.0", "phpstan/phpstan": "^1.8", "psr/container": "^1.1.1|^2.0.1", - "wikimedia/composer-merge-plugin": "^2.1", "kwn/php-rdkafka-stubs": "^2.2", "ecotone/dbal": "~1.309.3", "symfony/expression-language": "^6.4|^7.0|^8.0" From 2e59fba3e500db8c3d26402a5d1c41db9e811c1b Mon Sep 17 00:00:00 2001 From: Dariusz Gafka Date: Thu, 7 May 2026 18:27:47 +0200 Subject: [PATCH 13/13] fixes --- packages/Kafka/composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/Kafka/composer.json b/packages/Kafka/composer.json index 26eb3ff62..f2a2c121a 100644 --- a/packages/Kafka/composer.json +++ b/packages/Kafka/composer.json @@ -49,7 +49,7 @@ "phpstan/phpstan": "^1.8", "psr/container": "^1.1.1|^2.0.1", "kwn/php-rdkafka-stubs": "^2.2", - "ecotone/dbal": "~1.309.3", + "ecotone/dbal": "~1.311.0", "symfony/expression-language": "^6.4|^7.0|^8.0" }, "scripts": {