From 96b453905a6c4440c8963d16c467b123b27acf14 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Sun, 22 Feb 2026 19:42:48 +0400 Subject: [PATCH 01/23] feat: implement plugins --- src/Client/ScheduleClient.php | 29 +- src/Client/WorkflowClient.php | 59 ++- src/Interceptor/SimplePipelineProvider.php | 18 +- src/Plugin/AbstractPlugin.php | 33 ++ src/Plugin/ClientPluginContext.php | 81 ++++ src/Plugin/ClientPluginInterface.php | 36 ++ src/Plugin/ClientPluginTrait.php | 25 ++ src/Plugin/CompositePipelineProvider.php | 81 ++++ src/Plugin/PluginRegistry.php | 80 ++++ src/Plugin/ScheduleClientPluginContext.php | 51 +++ src/Plugin/ScheduleClientPluginInterface.php | 36 ++ src/Plugin/ScheduleClientPluginTrait.php | 25 ++ src/Plugin/TemporalPluginInterface.php | 23 ++ src/Plugin/WorkerFactoryPluginContext.php | 38 ++ src/Plugin/WorkerPluginContext.php | 87 ++++ src/Plugin/WorkerPluginInterface.php | 55 +++ src/Plugin/WorkerPluginTrait.php | 38 ++ src/Worker/WorkerFactoryInterface.php | 8 + src/WorkerFactory.php | 71 +++- testing/src/WorkerFactory.php | 42 +- .../Extra/Plugin/ClientPluginTest.php | 175 +++++++++ tests/Unit/Framework/WorkerFactoryMock.php | 5 + tests/Unit/Plugin/AbstractPluginTestCase.php | 86 ++++ .../Plugin/ClientPluginContextTestCase.php | 70 ++++ tests/Unit/Plugin/ClientPluginTestCase.php | 251 ++++++++++++ .../CompositePipelineProviderTestCase.php | 124 ++++++ .../Unit/Plugin/PluginPropagationTestCase.php | 237 +++++++++++ tests/Unit/Plugin/PluginRegistryTestCase.php | 129 ++++++ .../ScheduleClientPluginContextTestCase.php | 41 ++ .../Plugin/ScheduleClientPluginTestCase.php | 193 +++++++++ .../Plugin/WorkerFactoryPluginTestCase.php | 371 ++++++++++++++++++ .../Plugin/WorkerPluginContextTestCase.php | 62 +++ 32 files changed, 2647 insertions(+), 13 deletions(-) create mode 100644 src/Plugin/AbstractPlugin.php create mode 100644 src/Plugin/ClientPluginContext.php create mode 100644 src/Plugin/ClientPluginInterface.php create mode 100644 src/Plugin/ClientPluginTrait.php create mode 100644 src/Plugin/CompositePipelineProvider.php create mode 100644 src/Plugin/PluginRegistry.php create mode 100644 src/Plugin/ScheduleClientPluginContext.php create mode 100644 src/Plugin/ScheduleClientPluginInterface.php create mode 100644 src/Plugin/ScheduleClientPluginTrait.php create mode 100644 src/Plugin/TemporalPluginInterface.php create mode 100644 src/Plugin/WorkerFactoryPluginContext.php create mode 100644 src/Plugin/WorkerPluginContext.php create mode 100644 src/Plugin/WorkerPluginInterface.php create mode 100644 src/Plugin/WorkerPluginTrait.php create mode 100644 tests/Acceptance/Extra/Plugin/ClientPluginTest.php create mode 100644 tests/Unit/Plugin/AbstractPluginTestCase.php create mode 100644 tests/Unit/Plugin/ClientPluginContextTestCase.php create mode 100644 tests/Unit/Plugin/ClientPluginTestCase.php create mode 100644 tests/Unit/Plugin/CompositePipelineProviderTestCase.php create mode 100644 tests/Unit/Plugin/PluginPropagationTestCase.php create mode 100644 tests/Unit/Plugin/PluginRegistryTestCase.php create mode 100644 tests/Unit/Plugin/ScheduleClientPluginContextTestCase.php create mode 100644 tests/Unit/Plugin/ScheduleClientPluginTestCase.php create mode 100644 tests/Unit/Plugin/WorkerFactoryPluginTestCase.php create mode 100644 tests/Unit/Plugin/WorkerPluginContextTestCase.php diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index d97a0002c..cdf56f134 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -32,6 +32,9 @@ use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; use Temporal\Internal\Mapper\ScheduleMapper; +use Temporal\Plugin\PluginRegistry; +use Temporal\Plugin\ScheduleClientPluginContext; +use Temporal\Plugin\ScheduleClientPluginInterface; use Temporal\Internal\Marshaller\Mapper\AttributeMapperFactory; use Temporal\Internal\Marshaller\Marshaller; use Temporal\Internal\Marshaller\MarshallerInterface; @@ -46,13 +49,33 @@ final class ScheduleClient implements ScheduleClientInterface private MarshallerInterface $marshaller; private ProtoToArrayConverter $protoConverter; + /** + * @param list $plugins + */ public function __construct( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, ?DataConverterInterface $converter = null, + array $plugins = [], ) { $this->clientOptions = $options ?? new ClientOptions(); $this->converter = $converter ?? DataConverter::createDefault(); + + // Apply schedule client plugins + if ($plugins !== []) { + $pluginRegistry = new PluginRegistry($plugins); + $pluginContext = new ScheduleClientPluginContext( + clientOptions: $this->clientOptions, + dataConverter: $this->converter, + ); + foreach ($pluginRegistry->getPlugins(ScheduleClientPluginInterface::class) as $plugin) { + $plugin->configureScheduleClient($pluginContext); + } + $this->clientOptions = $pluginContext->getClientOptions(); + if ($pluginContext->getDataConverter() !== null) { + $this->converter = $pluginContext->getDataConverter(); + } + } $this->marshaller = new Marshaller( new AttributeMapperFactory(new AttributeReader()), ); @@ -67,12 +90,16 @@ public function __construct( ); } + /** + * @param list $plugins + */ public static function create( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, ?DataConverterInterface $converter = null, + array $plugins = [], ): ScheduleClientInterface { - return new self($serviceClient, $options, $converter); + return new self($serviceClient, $options, $converter, $plugins); } public function createSchedule( diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index e982fa5e9..1c70e6f27 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -38,6 +38,12 @@ use Temporal\Interceptor\WorkflowClientCallsInterceptor; use Temporal\Internal\Client\ActivityCompletionClient; use Temporal\Internal\Client\WorkflowProxy; +use Temporal\Plugin\ClientPluginContext; +use Temporal\Plugin\ClientPluginInterface; +use Temporal\Plugin\CompositePipelineProvider; +use Temporal\Plugin\PluginRegistry; +use Temporal\Plugin\ScheduleClientPluginInterface; +use Temporal\Plugin\WorkerPluginInterface; use Temporal\Internal\Client\WorkflowRun; use Temporal\Internal\Client\WorkflowStarter; use Temporal\Internal\Client\WorkflowStub; @@ -63,20 +69,45 @@ class WorkflowClient implements WorkflowClientInterface private DataConverterInterface $converter; private ?WorkflowStarter $starter = null; private WorkflowReader $reader; + private PluginRegistry $pluginRegistry; /** @var Pipeline */ private Pipeline $interceptorPipeline; + /** + * @param list $plugins + */ public function __construct( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, ?DataConverterInterface $converter = null, ?PipelineProvider $interceptorProvider = null, + array $plugins = [], ) { - $this->interceptorPipeline = ($interceptorProvider ?? new SimplePipelineProvider()) - ->getPipeline(WorkflowClientCallsInterceptor::class); + $this->pluginRegistry = new PluginRegistry($plugins); $this->clientOptions = $options ?? new ClientOptions(); $this->converter = $converter ?? DataConverter::createDefault(); + + $pluginContext = new ClientPluginContext( + clientOptions: $this->clientOptions, + dataConverter: $this->converter, + ); + foreach ($this->pluginRegistry->getPlugins(ClientPluginInterface::class) as $plugin) { + $plugin->configureClient($pluginContext); + } + + $this->clientOptions = $pluginContext->getClientOptions(); + if ($pluginContext->getDataConverter() !== null) { + $this->converter = $pluginContext->getDataConverter(); + } + + // Build interceptor pipeline: merge plugin-contributed interceptors with user-provided ones + $provider = new CompositePipelineProvider( + $pluginContext->getInterceptors(), + $interceptorProvider ?? new SimplePipelineProvider(), + ); + + $this->interceptorPipeline = $provider->getPipeline(WorkflowClientCallsInterceptor::class); $this->reader = new WorkflowReader($this->createReader()); // Set Temporal-Namespace metadata @@ -89,6 +120,7 @@ public function __construct( } /** + * @param list $plugins * @return static */ public static function create( @@ -96,8 +128,29 @@ public static function create( ?ClientOptions $options = null, ?DataConverterInterface $converter = null, ?PipelineProvider $interceptorProvider = null, + array $plugins = [], ): self { - return new self($serviceClient, $options, $converter, $interceptorProvider); + return new self($serviceClient, $options, $converter, $interceptorProvider, $plugins); + } + + /** + * Get plugins that also implement WorkerPluginInterface for propagation to workers. + * + * @return list + */ + public function getWorkerPlugins(): array + { + return $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + } + + /** + * Get plugins that also implement ScheduleClientPluginInterface for propagation to schedule clients. + * + * @return list + */ + public function getScheduleClientPlugins(): array + { + return $this->pluginRegistry->getPlugins(ScheduleClientPluginInterface::class); } public function getServiceClient(): ServiceClientInterface diff --git a/src/Interceptor/SimplePipelineProvider.php b/src/Interceptor/SimplePipelineProvider.php index 56360f6a1..ba0f1ab1d 100644 --- a/src/Interceptor/SimplePipelineProvider.php +++ b/src/Interceptor/SimplePipelineProvider.php @@ -22,14 +22,28 @@ class SimplePipelineProvider implements PipelineProvider * @param array $interceptors */ public function __construct( - private iterable $interceptors = [], + private readonly iterable $interceptors = [], ) {} + /** + * Create a new provider with additional interceptors prepended. + * + * @param list $interceptors Interceptors to prepend before existing ones. + */ + public function withPrependedInterceptors(array $interceptors): self + { + if ($interceptors === []) { + return $this; + } + + return new self(\array_merge($interceptors, [...$this->interceptors])); + } + public function getPipeline(string $interceptorClass): Pipeline { return $this->cache[$interceptorClass] ??= Pipeline::prepare( \array_filter( - $this->interceptors, + [...$this->interceptors], static fn(Interceptor $i): bool => $i instanceof $interceptorClass, ), ); diff --git a/src/Plugin/AbstractPlugin.php b/src/Plugin/AbstractPlugin.php new file mode 100644 index 000000000..9b1d22ad4 --- /dev/null +++ b/src/Plugin/AbstractPlugin.php @@ -0,0 +1,33 @@ +name; + } +} diff --git a/src/Plugin/ClientPluginContext.php b/src/Plugin/ClientPluginContext.php new file mode 100644 index 000000000..fad098ecd --- /dev/null +++ b/src/Plugin/ClientPluginContext.php @@ -0,0 +1,81 @@ + */ + private array $interceptors = []; + + public function __construct( + private ClientOptions $clientOptions, + private ?DataConverterInterface $dataConverter = null, + ) {} + + public function getClientOptions(): ClientOptions + { + return $this->clientOptions; + } + + public function setClientOptions(ClientOptions $clientOptions): self + { + $this->clientOptions = $clientOptions; + return $this; + } + + public function getDataConverter(): ?DataConverterInterface + { + return $this->dataConverter; + } + + public function setDataConverter(?DataConverterInterface $dataConverter): self + { + $this->dataConverter = $dataConverter; + return $this; + } + + /** + * @return list + */ + public function getInterceptors(): array + { + return $this->interceptors; + } + + /** + * @param list $interceptors + */ + public function setInterceptors(array $interceptors): self + { + $this->interceptors = $interceptors; + return $this; + } + + /** + * Add an interceptor to the client pipeline. + */ + public function addInterceptor(Interceptor $interceptor): self + { + $this->interceptors[] = $interceptor; + return $this; + } +} diff --git a/src/Plugin/ClientPluginInterface.php b/src/Plugin/ClientPluginInterface.php new file mode 100644 index 000000000..e32d2a186 --- /dev/null +++ b/src/Plugin/ClientPluginInterface.php @@ -0,0 +1,36 @@ + $pluginInterceptors Interceptors contributed by plugins. + * @param PipelineProvider $baseProvider The original user-provided pipeline provider. + */ + public function __construct( + array $pluginInterceptors, + PipelineProvider $baseProvider, + ) { + $this->delegate = match (true) { + $pluginInterceptors === [] => $baseProvider, + $baseProvider instanceof SimplePipelineProvider => $baseProvider->withPrependedInterceptors($pluginInterceptors), + default => new class($pluginInterceptors, $baseProvider) implements PipelineProvider { + /** @var array */ + private array $cache = []; + + /** + * @param list $pluginInterceptors + */ + public function __construct( + private readonly array $pluginInterceptors, + private readonly PipelineProvider $baseProvider, + ) {} + + public function getPipeline(string $interceptorClass): Pipeline + { + if (isset($this->cache[$interceptorClass])) { + return $this->cache[$interceptorClass]; + } + + $filtered = \array_filter( + $this->pluginInterceptors, + static fn(Interceptor $i): bool => $i instanceof $interceptorClass, + ); + + if ($filtered === []) { + return $this->cache[$interceptorClass] = $this->baseProvider->getPipeline($interceptorClass); + } + + // Use only plugin interceptors - the base pipeline is lost in this edge case. + // Users should either use plugins OR a custom PipelineProvider, not both. + return $this->cache[$interceptorClass] = Pipeline::prepare($filtered); + } + }, + }; + } + + public function getPipeline(string $interceptorClass): Pipeline + { + return $this->delegate->getPipeline($interceptorClass); + } +} diff --git a/src/Plugin/PluginRegistry.php b/src/Plugin/PluginRegistry.php new file mode 100644 index 000000000..fb15c03af --- /dev/null +++ b/src/Plugin/PluginRegistry.php @@ -0,0 +1,80 @@ + */ + private array $plugins = []; + + /** + * @param iterable $plugins + */ + public function __construct(iterable $plugins = []) + { + foreach ($plugins as $plugin) { + $this->add($plugin); + } + } + + public function add(ClientPluginInterface|ScheduleClientPluginInterface|WorkerPluginInterface $plugin): void + { + $name = $plugin->getName(); + if (isset($this->plugins[$name])) { + throw new \RuntimeException(\sprintf( + 'Duplicate plugin "%s": a plugin with this name is already registered.', + $name, + )); + } + $this->plugins[$name] = $plugin; + } + + /** + * Merge another set of plugins. Throws on duplicate names. + * + * @param iterable $plugins + */ + public function merge(iterable $plugins): void + { + foreach ($plugins as $plugin) { + $this->add($plugin); + } + } + + /** + * Get all plugins implementing a given interface. + * + * @template T of TPlugin + * @param class-string $interface + * @return list + */ + public function getPlugins(string $interface): array + { + $result = []; + foreach ($this->plugins as $plugin) { + if ($plugin instanceof $interface) { + $result[] = $plugin; + } + } + return $result; + } +} diff --git a/src/Plugin/ScheduleClientPluginContext.php b/src/Plugin/ScheduleClientPluginContext.php new file mode 100644 index 000000000..c38ce683e --- /dev/null +++ b/src/Plugin/ScheduleClientPluginContext.php @@ -0,0 +1,51 @@ +clientOptions; + } + + public function setClientOptions(ClientOptions $clientOptions): self + { + $this->clientOptions = $clientOptions; + return $this; + } + + public function getDataConverter(): ?DataConverterInterface + { + return $this->dataConverter; + } + + public function setDataConverter(?DataConverterInterface $dataConverter): self + { + $this->dataConverter = $dataConverter; + return $this; + } +} diff --git a/src/Plugin/ScheduleClientPluginInterface.php b/src/Plugin/ScheduleClientPluginInterface.php new file mode 100644 index 000000000..3f2dcd9a7 --- /dev/null +++ b/src/Plugin/ScheduleClientPluginInterface.php @@ -0,0 +1,36 @@ +dataConverter; + } + + public function setDataConverter(?DataConverterInterface $dataConverter): self + { + $this->dataConverter = $dataConverter; + return $this; + } +} diff --git a/src/Plugin/WorkerPluginContext.php b/src/Plugin/WorkerPluginContext.php new file mode 100644 index 000000000..9c36311d2 --- /dev/null +++ b/src/Plugin/WorkerPluginContext.php @@ -0,0 +1,87 @@ + */ + private array $interceptors = []; + + public function __construct( + private readonly string $taskQueue, + private WorkerOptions $workerOptions, + private ?ExceptionInterceptorInterface $exceptionInterceptor = null, + ) {} + + public function getTaskQueue(): string + { + return $this->taskQueue; + } + + public function getWorkerOptions(): WorkerOptions + { + return $this->workerOptions; + } + + public function setWorkerOptions(WorkerOptions $workerOptions): self + { + $this->workerOptions = $workerOptions; + return $this; + } + + public function getExceptionInterceptor(): ?ExceptionInterceptorInterface + { + return $this->exceptionInterceptor; + } + + public function setExceptionInterceptor(?ExceptionInterceptorInterface $exceptionInterceptor): self + { + $this->exceptionInterceptor = $exceptionInterceptor; + return $this; + } + + /** + * @return list + */ + public function getInterceptors(): array + { + return $this->interceptors; + } + + /** + * @param list $interceptors + */ + public function setInterceptors(array $interceptors): self + { + $this->interceptors = $interceptors; + return $this; + } + + /** + * Add an interceptor to the worker pipeline. + */ + public function addInterceptor(Interceptor $interceptor): self + { + $this->interceptors[] = $interceptor; + return $this; + } +} diff --git a/src/Plugin/WorkerPluginInterface.php b/src/Plugin/WorkerPluginInterface.php new file mode 100644 index 000000000..1540c96c2 --- /dev/null +++ b/src/Plugin/WorkerPluginInterface.php @@ -0,0 +1,55 @@ + + */ + public function getWorkerPlugins(): array; + /** * Start processing workflows and activities processing. */ diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index a8496f2e7..0efbe2b47 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -19,6 +19,7 @@ use Spiral\Attributes\AttributeReader; use Spiral\Attributes\Composite\SelectiveReader; use Spiral\Attributes\ReaderInterface; +use Temporal\Client\WorkflowClient; use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; use Temporal\Exception\ExceptionInterceptor; @@ -26,6 +27,11 @@ use Temporal\Interceptor\PipelineProvider; use Temporal\Interceptor\SimplePipelineProvider; use Temporal\Internal\Events\EventEmitterTrait; +use Temporal\Plugin\CompositePipelineProvider; +use Temporal\Plugin\PluginRegistry; +use Temporal\Plugin\WorkerFactoryPluginContext; +use Temporal\Plugin\WorkerPluginContext; +use Temporal\Plugin\WorkerPluginInterface; use Temporal\Internal\Marshaller\Mapper\AttributeMapperFactory; use Temporal\Internal\Marshaller\Marshaller; use Temporal\Internal\Marshaller\MarshallerInterface; @@ -105,24 +111,53 @@ class WorkerFactory implements WorkerFactoryInterface, LoopInterface protected EnvironmentInterface $env; + protected PluginRegistry $pluginRegistry; + + /** + * @param list $plugins Worker plugins to register. + */ public function __construct( DataConverterInterface $dataConverter, protected RPCConnectionInterface $rpc, ?ServiceCredentials $credentials = null, + array $plugins = [], + ?WorkflowClient $client = null, ) { - $this->converter = $dataConverter; + $this->pluginRegistry = new PluginRegistry($plugins); + + // Propagate worker plugins from the client + if ($client !== null) { + $this->pluginRegistry->merge($client->getWorkerPlugins()); + } + + // Apply worker factory plugins + $factoryContext = new WorkerFactoryPluginContext( + dataConverter: $dataConverter, + ); + foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { + $plugin->configureWorkerFactory($factoryContext); + } + + $this->converter = $factoryContext->getDataConverter() ?? $dataConverter; $this->boot($credentials ?? ServiceCredentials::create()); } + /** + * @param list $plugins Worker plugins to register. + */ public static function create( ?DataConverterInterface $converter = null, ?RPCConnectionInterface $rpc = null, ?ServiceCredentials $credentials = null, + array $plugins = [], + ?WorkflowClient $client = null, ): static { return new static( $converter ?? DataConverter::createDefault(), $rpc ?? Goridge::create(), $credentials, + $plugins, + $client, ); } @@ -134,13 +169,32 @@ public function newWorker( ?LoggerInterface $logger = null, ): WorkerInterface { $options ??= WorkerOptions::new(); + + // Apply worker plugins + $workerContext = new WorkerPluginContext( + taskQueue: $taskQueue, + workerOptions: $options, + exceptionInterceptor: $exceptionInterceptor, + ); + foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { + $plugin->configureWorker($workerContext); + } + + $options = $workerContext->getWorkerOptions(); + + // Merge plugin-contributed interceptors with user-provided ones + $provider = new CompositePipelineProvider( + $workerContext->getInterceptors(), + $interceptorProvider ?? new SimplePipelineProvider(), + ); + $worker = new Worker( $taskQueue, $options, ServiceContainer::fromWorkerFactory( $this, - $exceptionInterceptor ?? ExceptionInterceptor::createDefault(), - $interceptorProvider ?? new SimplePipelineProvider(), + $workerContext->getExceptionInterceptor() ?? ExceptionInterceptor::createDefault(), + $provider, new Logger( $logger ?? new StderrLogger(), $options->enableLoggingInReplay, @@ -149,11 +203,22 @@ public function newWorker( ), $this->rpc, ); + + // Call initializeWorker hooks (forward order) + foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { + $plugin->initializeWorker($worker); + } + $this->queues->add($worker); return $worker; } + public function getWorkerPlugins(): array + { + return $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + } + public function getReader(): ReaderInterface { return $this->reader; diff --git a/testing/src/WorkerFactory.php b/testing/src/WorkerFactory.php index 181b3bca0..2e626719e 100644 --- a/testing/src/WorkerFactory.php +++ b/testing/src/WorkerFactory.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; +use Temporal\Client\WorkflowClient; use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; use Temporal\Exception\ExceptionInterceptor; @@ -14,6 +15,9 @@ use Temporal\Interceptor\SimplePipelineProvider; use Temporal\Internal\ServiceContainer; use Temporal\Internal\Workflow\Logger; +use Temporal\Plugin\CompositePipelineProvider; +use Temporal\Plugin\WorkerPluginContext; +use Temporal\Plugin\WorkerPluginInterface; use Temporal\Worker\ActivityInvocationCache\ActivityInvocationCacheInterface; use Temporal\Worker\ActivityInvocationCache\RoadRunnerActivityInvocationCache; use Temporal\Worker\ServiceCredentials; @@ -32,16 +36,20 @@ public function __construct( RPCConnectionInterface $rpc, ActivityInvocationCacheInterface $activityCache, ?ServiceCredentials $credentials = null, + array $plugins = [], + ?WorkflowClient $client = null, ) { $this->activityCache = $activityCache; - parent::__construct($dataConverter, $rpc, $credentials ?? ServiceCredentials::create()); + parent::__construct($dataConverter, $rpc, $credentials ?? ServiceCredentials::create(), $plugins, $client); } public static function create( ?DataConverterInterface $converter = null, ?RPCConnectionInterface $rpc = null, ?ServiceCredentials $credentials = null, + array $plugins = [], + ?WorkflowClient $client = null, ?ActivityInvocationCacheInterface $activityCache = null, ): static { return new static( @@ -49,6 +57,8 @@ public static function create( $rpc ?? Goridge::create(), $activityCache ?? RoadRunnerActivityInvocationCache::create($converter), $credentials, + $plugins, + $client, ); } @@ -60,14 +70,32 @@ public function newWorker( ?LoggerInterface $logger = null, ): WorkerInterface { $options ??= WorkerOptions::new(); + + $workerContext = new WorkerPluginContext( + taskQueue: $taskQueue, + workerOptions: $options, + exceptionInterceptor: $exceptionInterceptor, + ); + foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { + $plugin->configureWorker($workerContext); + } + + $options = $workerContext->getWorkerOptions(); + + // Merge plugin-contributed interceptors with user-provided ones + $provider = new CompositePipelineProvider( + $workerContext->getInterceptors(), + $interceptorProvider ?? new SimplePipelineProvider(), + ); + $worker = new WorkerMock( new Worker( $taskQueue, - $options ?? WorkerOptions::new(), + $options, ServiceContainer::fromWorkerFactory( $this, - $exceptionInterceptor ?? ExceptionInterceptor::createDefault(), - $interceptorProvider ?? new SimplePipelineProvider(), + $workerContext->getExceptionInterceptor() ?? ExceptionInterceptor::createDefault(), + $provider, new Logger( $logger ?? new NullLogger(), $options->enableLoggingInReplay, @@ -78,6 +106,12 @@ public function newWorker( ), $this->activityCache, ); + + // Call initializeWorker hooks (forward order) + foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { + $plugin->initializeWorker($worker); + } + $this->queues->add($worker); return $worker; diff --git a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php new file mode 100644 index 000000000..be287490c --- /dev/null +++ b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php @@ -0,0 +1,175 @@ +getServiceClient(), + options: (new ClientOptions())->withNamespace($runtime->namespace), + plugins: [new PrefixPlugin()], + )->withTimeout(5); + + $stub = $pluginClient->newUntypedWorkflowStub( + 'Extra_Plugin_ClientPlugin', + WorkflowOptions::new()->withTaskQueue($feature->taskQueue), + ); + $pluginClient->start($stub, 'hello'); + + $result = $stub->getResult('string'); + self::assertSame('plugin:hello', $result); + } + + /** + * Multiple plugins apply interceptors in registration order. + */ + #[Test] + public function multiplePluginsApplyInOrder( + WorkflowClientInterface $client, + Feature $feature, + State $runtime, + ): void { + $pluginClient = WorkflowClient::create( + serviceClient: $client->getServiceClient(), + options: (new ClientOptions())->withNamespace($runtime->namespace), + plugins: [new PrefixPlugin('A:'), new PrefixPlugin2('B:')], + )->withTimeout(5); + + $stub = $pluginClient->newUntypedWorkflowStub( + 'Extra_Plugin_ClientPlugin', + WorkflowOptions::new()->withTaskQueue($feature->taskQueue), + ); + $pluginClient->start($stub, 'test'); + + $result = $stub->getResult('string'); + // Plugin interceptors prepend, so A runs first, then B + self::assertSame('B:A:test', $result); + } + + /** + * Duplicate plugin names throw exception. + */ + #[Test] + public function duplicatePluginThrowsException( + WorkflowClientInterface $client, + State $runtime, + ): void { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "prefix-plugin"'); + + WorkflowClient::create( + serviceClient: $client->getServiceClient(), + options: (new ClientOptions())->withNamespace($runtime->namespace), + plugins: [new PrefixPlugin(), new PrefixPlugin()], + ); + } + + /** + * Client without plugins works normally. + */ + #[Test] + public function noPluginsWorkflow( + #[Stub('Extra_Plugin_ClientPlugin', args: ['world'])] + WorkflowStubInterface $stub, + ): void { + self::assertSame('world', $stub->getResult('string')); + } +} + + +#[WorkflowInterface] +class TestWorkflow +{ + #[WorkflowMethod(name: 'Extra_Plugin_ClientPlugin')] + public function handle(string $input) + { + return $input; + } +} + + +class PrefixPlugin implements ClientPluginInterface +{ + public function __construct( + private readonly string $prefix = 'plugin:', + ) {} + + public function getName(): string + { + return 'prefix-plugin'; + } + + public function configureClient(ClientPluginContext $context): void + { + $context->addInterceptor(new PrefixInterceptor($this->prefix)); + } +} + + +class PrefixPlugin2 implements ClientPluginInterface +{ + public function __construct( + private readonly string $prefix = 'plugin2:', + ) {} + + public function getName(): string + { + return 'prefix-plugin-2'; + } + + public function configureClient(ClientPluginContext $context): void + { + $context->addInterceptor(new PrefixInterceptor($this->prefix)); + } +} + + +class PrefixInterceptor implements WorkflowClientCallsInterceptor +{ + use WorkflowClientCallsInterceptorTrait; + + public function __construct( + private readonly string $prefix, + ) {} + + public function start(StartInput $input, callable $next): WorkflowExecution + { + $original = $input->arguments->getValue(0, 'string'); + + return $next($input->with( + arguments: EncodedValues::fromValues([$this->prefix . $original], DataConverter::createDefault()), + )); + } +} diff --git a/tests/Unit/Framework/WorkerFactoryMock.php b/tests/Unit/Framework/WorkerFactoryMock.php index 07b1779e9..2455dd78e 100644 --- a/tests/Unit/Framework/WorkerFactoryMock.php +++ b/tests/Unit/Framework/WorkerFactoryMock.php @@ -112,6 +112,11 @@ public function newWorker( return $worker; } + public function getWorkerPlugins(): array + { + return []; + } + public function getReader(): ReaderInterface { return $this->reader; diff --git a/tests/Unit/Plugin/AbstractPluginTestCase.php b/tests/Unit/Plugin/AbstractPluginTestCase.php new file mode 100644 index 000000000..84f2dbac3 --- /dev/null +++ b/tests/Unit/Plugin/AbstractPluginTestCase.php @@ -0,0 +1,86 @@ +getName()); + } + + public function testConfigureClientPassthrough(): void + { + $plugin = new class('noop') extends AbstractPlugin {}; + $context = new ClientPluginContext(new ClientOptions()); + + $clone = clone $context; + $plugin->configureClient($context); + + self::assertSame($clone->getClientOptions(), $context->getClientOptions()); + self::assertSame($clone->getDataConverter(), $context->getDataConverter()); + } + + public function testConfigureScheduleClientPassthrough(): void + { + $plugin = new class('noop') extends AbstractPlugin {}; + $context = new ScheduleClientPluginContext(new ClientOptions()); + + $clone = clone $context; + $plugin->configureScheduleClient($context); + + self::assertSame($clone->getClientOptions(), $context->getClientOptions()); + self::assertSame($clone->getDataConverter(), $context->getDataConverter()); + } + + public function testConfigureWorkerFactoryPassthrough(): void + { + $plugin = new class('noop') extends AbstractPlugin {}; + $context = new WorkerFactoryPluginContext(); + + $clone = clone $context; + $plugin->configureWorkerFactory($context); + + self::assertSame($clone->getDataConverter(), $context->getDataConverter()); + } + + public function testConfigureWorkerPassthrough(): void + { + $plugin = new class('noop') extends AbstractPlugin {}; + $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); + + $clone = clone $context; + $plugin->configureWorker($context); + + self::assertSame($clone->getWorkerOptions(), $context->getWorkerOptions()); + self::assertSame($clone->getExceptionInterceptor(), $context->getExceptionInterceptor()); + } + + public function testInitializeWorkerNoop(): void + { + $plugin = new class('noop') extends AbstractPlugin {}; + $worker = $this->createMock(WorkerInterface::class); + + // Should not throw + $plugin->initializeWorker($worker); + self::assertTrue(true); + } +} diff --git a/tests/Unit/Plugin/ClientPluginContextTestCase.php b/tests/Unit/Plugin/ClientPluginContextTestCase.php new file mode 100644 index 000000000..bd2bdf644 --- /dev/null +++ b/tests/Unit/Plugin/ClientPluginContextTestCase.php @@ -0,0 +1,70 @@ +getClientOptions()); + self::assertNull($context->getDataConverter()); + self::assertSame([], $context->getInterceptors()); + } + + public function testSetters(): void + { + $context = new ClientPluginContext(new ClientOptions()); + $newOptions = new ClientOptions(); + $converter = $this->createMock(DataConverterInterface::class); + + $result = $context + ->setClientOptions($newOptions) + ->setDataConverter($converter); + + self::assertSame($context, $result); + self::assertSame($newOptions, $context->getClientOptions()); + self::assertSame($converter, $context->getDataConverter()); + } + + public function testAddInterceptor(): void + { + $context = new ClientPluginContext(new ClientOptions()); + + $interceptor = new class implements WorkflowClientCallsInterceptor { + use WorkflowClientCallsInterceptorTrait; + }; + $result = $context->addInterceptor($interceptor); + + self::assertSame($context, $result); + self::assertCount(1, $context->getInterceptors()); + self::assertSame($interceptor, $context->getInterceptors()[0]); + } + + public function testSetInterceptors(): void + { + $context = new ClientPluginContext(new ClientOptions()); + + $interceptor = new class implements WorkflowClientCallsInterceptor { + use WorkflowClientCallsInterceptorTrait; + }; + $context->setInterceptors([$interceptor]); + + self::assertCount(1, $context->getInterceptors()); + } +} diff --git a/tests/Unit/Plugin/ClientPluginTestCase.php b/tests/Unit/Plugin/ClientPluginTestCase.php new file mode 100644 index 000000000..4e1b6e096 --- /dev/null +++ b/tests/Unit/Plugin/ClientPluginTestCase.php @@ -0,0 +1,251 @@ +called = true; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertTrue($called); + } + + public function testPluginModifiesClientOptions(): void + { + $plugin = new class implements ClientPluginInterface { + use ClientPluginTrait; + + public function getName(): string + { + return 'test.namespace'; + } + + public function configureClient(ClientPluginContext $context): void + { + $context->setClientOptions( + (new ClientOptions())->withNamespace('plugin-namespace'), + ); + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + // The namespace metadata is set from plugin-modified options + self::assertNotNull($client->getServiceClient()); + } + + public function testPluginModifiesDataConverter(): void + { + $customConverter = $this->createMock(DataConverterInterface::class); + + $plugin = new class($customConverter) implements ClientPluginInterface { + use ClientPluginTrait; + + public function __construct(private DataConverterInterface $converter) {} + + public function getName(): string + { + return 'test.converter'; + } + + public function configureClient(ClientPluginContext $context): void + { + $context->setDataConverter($this->converter); + } + }; + + // Should not throw — converter is applied + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + self::assertNotNull($client); + } + + public function testPluginAddsInterceptor(): void + { + $interceptor = new class implements WorkflowClientCallsInterceptor { + use WorkflowClientCallsInterceptorTrait; + }; + + $plugin = new class($interceptor) implements ClientPluginInterface { + use ClientPluginTrait; + + public function __construct(private WorkflowClientCallsInterceptor $interceptor) {} + + public function getName(): string + { + return 'test.interceptor'; + } + + public function configureClient(ClientPluginContext $context): void + { + $context->addInterceptor($this->interceptor); + } + }; + + // Should not throw — interceptor pipeline is built with plugin interceptor + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + self::assertNotNull($client); + } + + public function testMultiplePluginsCalledInOrder(): void + { + $order = []; + + $plugin1 = new class($order) implements ClientPluginInterface { + use ClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.first'; + } + + public function configureClient(ClientPluginContext $context): void + { + $this->order[] = 'first'; + } + }; + + $plugin2 = new class($order) implements ClientPluginInterface { + use ClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.second'; + } + + public function configureClient(ClientPluginContext $context): void + { + $this->order[] = 'second'; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + + self::assertSame(['first', 'second'], $order); + } + + public function testDuplicatePluginThrowsException(): void + { + $plugin1 = new class('dup') extends AbstractPlugin {}; + $plugin2 = new class('dup') extends AbstractPlugin {}; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "dup"'); + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + } + + public function testGetWorkerPluginsPropagation(): void + { + $plugin = new class('combo') extends AbstractPlugin {}; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + $workerPlugins = $client->getWorkerPlugins(); + self::assertCount(1, $workerPlugins); + self::assertSame($plugin, $workerPlugins[0]); + } + + public function testGetScheduleClientPluginsPropagation(): void + { + $plugin = new class('combo') extends AbstractPlugin {}; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + $schedulePlugins = $client->getScheduleClientPlugins(); + self::assertCount(1, $schedulePlugins); + self::assertSame($plugin, $schedulePlugins[0]); + } + + public function testClientOnlyPluginNotPropagatedToWorkers(): void + { + $plugin = new class implements ClientPluginInterface { + use ClientPluginTrait; + + public function getName(): string + { + return 'client-only'; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertCount(0, $client->getWorkerPlugins()); + self::assertCount(0, $client->getScheduleClientPlugins()); + } + + public function testWorkerOnlyPluginNotPropagatedToScheduleClient(): void + { + $plugin = new class implements ClientPluginInterface, WorkerPluginInterface { + use ClientPluginTrait; + use WorkerPluginTrait; + + public function getName(): string + { + return 'client-worker'; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertCount(1, $client->getWorkerPlugins()); + self::assertCount(0, $client->getScheduleClientPlugins()); + } + + private function mockServiceClient(): ServiceClientInterface + { + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturn($context); + + $client = $this->createMock(ServiceClientInterface::class); + $client->method('getContext')->willReturn($context); + $client->method('withContext')->willReturn($client); + + return $client; + } +} diff --git a/tests/Unit/Plugin/CompositePipelineProviderTestCase.php b/tests/Unit/Plugin/CompositePipelineProviderTestCase.php new file mode 100644 index 000000000..4a8bb3673 --- /dev/null +++ b/tests/Unit/Plugin/CompositePipelineProviderTestCase.php @@ -0,0 +1,124 @@ +getPipeline(TestInvokeInterceptor::class); + /** @see TestInvokeInterceptor::__invoke() */ + $result = $pipeline->with(static fn(string $s) => $s, '__invoke')('_'); + + self::assertSame('_A', $result); + } + + public function testPluginInterceptorsPrependedToSimpleProvider(): void + { + $first = new TestOrderInterceptor('1'); + $second = new TestOrderInterceptor('2'); + + $baseProvider = new SimplePipelineProvider([$second]); + $composite = new CompositePipelineProvider([$first], $baseProvider); + + $pipeline = $composite->getPipeline(TestOrderInterceptor::class); + /** @see TestOrderInterceptor::handle() */ + $result = $pipeline->with(static fn(string $s) => $s, 'handle')('_'); + + // Plugin interceptor ($first) runs before base ($second) + self::assertSame('_12', $result); + } + + public function testPipelineCaching(): void + { + $composite = new CompositePipelineProvider([], new SimplePipelineProvider()); + + $pipeline1 = $composite->getPipeline(TestOrderInterceptor::class); + $pipeline2 = $composite->getPipeline(TestOrderInterceptor::class); + + self::assertSame($pipeline1, $pipeline2); + } + + public function testCustomPipelineProviderWithPluginInterceptors(): void + { + // Custom provider that doesn't extend SimplePipelineProvider + $customProvider = new class implements PipelineProvider { + public function getPipeline(string $interceptorClass): Pipeline + { + return Pipeline::prepare([]); + } + }; + + $interceptor = new TestOrderInterceptor('P'); + $composite = new CompositePipelineProvider([$interceptor], $customProvider); + + $pipeline = $composite->getPipeline(TestOrderInterceptor::class); + /** @see TestOrderInterceptor::handle() */ + $result = $pipeline->with(static fn(string $s) => $s, 'handle')('_'); + + self::assertSame('_P', $result); + } + + public function testEmptyPluginInterceptorsWithCustomProvider(): void + { + $customProvider = new class implements PipelineProvider { + public function getPipeline(string $interceptorClass): Pipeline + { + return Pipeline::prepare([new TestOrderInterceptor('X')]); + } + }; + + $composite = new CompositePipelineProvider([], $customProvider); + $pipeline = $composite->getPipeline(TestOrderInterceptor::class); + /** @see TestOrderInterceptor::handle() */ + $result = $pipeline->with(static fn(string $s) => $s, 'handle')('_'); + + self::assertSame('_X', $result); + } +} + +/** + * Test interceptor that appends a tag to the input string. + * @internal + */ +class TestOrderInterceptor implements Interceptor +{ + public function __construct(private readonly string $tag) {} + + public function handle(string $s, callable $next): string + { + return $next($s . $this->tag); + } +} + +/** + * Test interceptor using __invoke. + * @internal + */ +class TestInvokeInterceptor implements Interceptor +{ + public function __construct(private readonly string $tag) {} + + public function __invoke(string $s, callable $next): string + { + return $next($s . $this->tag); + } +} diff --git a/tests/Unit/Plugin/PluginPropagationTestCase.php b/tests/Unit/Plugin/PluginPropagationTestCase.php new file mode 100644 index 000000000..cd27bf64b --- /dev/null +++ b/tests/Unit/Plugin/PluginPropagationTestCase.php @@ -0,0 +1,237 @@ +order[] = 'configureClient'; + } + + public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + { + $this->order[] = 'configureWorkerFactory'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'configureWorker'; + } + + public function initializeWorker(WorkerInterface $worker): void + { + $this->order[] = 'initializeWorker'; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertSame(['configureClient'], $order); + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + client: $client, + ); + + self::assertSame(['configureClient', 'configureWorkerFactory'], $order); + + $factory->newWorker('test-queue'); + + self::assertSame([ + 'configureClient', + 'configureWorkerFactory', + 'configureWorker', + 'initializeWorker', + ], $order); + } + + public function testPluginFromClientMergesWithFactoryPlugins(): void + { + $order = []; + + $clientPlugin = new class($order) extends AbstractPlugin { + public function __construct(private array &$order) + { + parent::__construct('test.from-client'); + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'from-client'; + } + }; + + $factoryPlugin = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.from-factory'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'from-factory'; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$clientPlugin]); + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$factoryPlugin], + client: $client, + ); + $factory->newWorker(); + + self::assertSame(['from-factory', 'from-client'], $order); + } + + public function testDuplicateAcrossClientAndFactoryThrows(): void + { + $clientPlugin = new class('shared-name') extends AbstractPlugin {}; + + $factoryPlugin = new class implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function getName(): string + { + return 'shared-name'; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$clientPlugin]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "shared-name"'); + + new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$factoryPlugin], + client: $client, + ); + } + + public function testClientOnlyPluginNotPropagatedToFactory(): void + { + $factoryConfigureCalled = false; + + $plugin = new class($factoryConfigureCalled) implements ClientPluginInterface { + use ClientPluginTrait; + + public function __construct(private bool &$called) {} + + public function getName(): string + { + return 'test.client-only'; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + // Client-only plugin should NOT appear in getWorkerPlugins + self::assertCount(0, $client->getWorkerPlugins()); + + // Factory should work fine without this plugin + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + client: $client, + ); + $factory->newWorker(); + + self::assertCount(0, $factory->getWorkerPlugins()); + } + + public function testScheduleClientPluginPropagation(): void + { + $called = false; + + $plugin = new class($called) implements ClientPluginInterface, ScheduleClientPluginInterface { + use ClientPluginTrait; + use ScheduleClientPluginTrait; + + public function __construct(private bool &$called) {} + + public function getName(): string + { + return 'test.schedule-combo'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context): void + { + $this->called = true; + } + }; + + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + $schedulePlugins = $client->getScheduleClientPlugins(); + self::assertCount(1, $schedulePlugins); + self::assertSame($plugin, $schedulePlugins[0]); + } + + private function mockServiceClient(): ServiceClientInterface + { + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturn($context); + + $client = $this->createMock(ServiceClientInterface::class); + $client->method('getContext')->willReturn($context); + $client->method('withContext')->willReturn($client); + + return $client; + } + + private function mockRpc(): RPCConnectionInterface + { + return $this->createMock(RPCConnectionInterface::class); + } +} diff --git a/tests/Unit/Plugin/PluginRegistryTestCase.php b/tests/Unit/Plugin/PluginRegistryTestCase.php new file mode 100644 index 000000000..bddec2f5b --- /dev/null +++ b/tests/Unit/Plugin/PluginRegistryTestCase.php @@ -0,0 +1,129 @@ +createPlugin('plugin-1'); + $plugin2 = $this->createPlugin('plugin-2'); + + $registry = new PluginRegistry([$plugin1, $plugin2]); + + // AbstractPlugin implements all three interfaces (via TemporalPluginInterface), + // so we can retrieve both via any of them + $plugins = $registry->getPlugins(ClientPluginInterface::class); + self::assertCount(2, $plugins); + self::assertSame($plugin1, $plugins[0]); + self::assertSame($plugin2, $plugins[1]); + } + + public function testDuplicateThrowsException(): void + { + $plugin1 = $this->createPlugin('my-plugin'); + $plugin2 = $this->createPlugin('my-plugin'); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "my-plugin"'); + + new PluginRegistry([$plugin1, $plugin2]); + } + + public function testDuplicateViaAddThrowsException(): void + { + $registry = new PluginRegistry(); + $registry->add($this->createPlugin('dup-plugin')); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "dup-plugin"'); + + $registry->add($this->createPlugin('dup-plugin')); + } + + public function testMergeThrowsOnDuplicates(): void + { + $plugin1 = $this->createPlugin('plugin-a'); + $plugin2 = $this->createPlugin('plugin-b'); + $plugin3 = $this->createPlugin('plugin-a'); // duplicate + + $registry = new PluginRegistry([$plugin1]); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "plugin-a"'); + + $registry->merge([$plugin2, $plugin3]); + } + + public function testGetPluginsByInterface(): void + { + $clientPlugin = new class implements ClientPluginInterface { + public function getName(): string + { + return 'client-only'; + } + + public function configureClient(ClientPluginContext $context): void {} + }; + + $workerPlugin = new class implements WorkerPluginInterface { + public function getName(): string + { + return 'worker-only'; + } + + public function configureWorkerFactory(WorkerFactoryPluginContext $context): void {} + + public function configureWorker(WorkerPluginContext $context): void {} + + public function initializeWorker(WorkerInterface $worker): void {} + }; + + $bothPlugin = $this->createPlugin('both'); + + $registry = new PluginRegistry([$clientPlugin, $workerPlugin, $bothPlugin]); + + $clientPlugins = $registry->getPlugins(ClientPluginInterface::class); + self::assertCount(2, $clientPlugins); + self::assertSame($clientPlugin, $clientPlugins[0]); + self::assertSame($bothPlugin, $clientPlugins[1]); + + $workerPlugins = $registry->getPlugins(WorkerPluginInterface::class); + self::assertCount(2, $workerPlugins); + self::assertSame($workerPlugin, $workerPlugins[0]); + self::assertSame($bothPlugin, $workerPlugins[1]); + + $schedulePlugins = $registry->getPlugins(ScheduleClientPluginInterface::class); + self::assertCount(1, $schedulePlugins); + self::assertSame($bothPlugin, $schedulePlugins[0]); + } + + public function testEmptyRegistry(): void + { + $registry = new PluginRegistry(); + + self::assertSame([], $registry->getPlugins(ClientPluginInterface::class)); + } + + private function createPlugin(string $name): AbstractPlugin + { + return new class($name) extends AbstractPlugin {}; + } +} diff --git a/tests/Unit/Plugin/ScheduleClientPluginContextTestCase.php b/tests/Unit/Plugin/ScheduleClientPluginContextTestCase.php new file mode 100644 index 000000000..32133950c --- /dev/null +++ b/tests/Unit/Plugin/ScheduleClientPluginContextTestCase.php @@ -0,0 +1,41 @@ +getClientOptions()); + self::assertNull($context->getDataConverter()); + } + + public function testSetters(): void + { + $context = new ScheduleClientPluginContext(new ClientOptions()); + $newOptions = new ClientOptions(); + $converter = $this->createMock(DataConverterInterface::class); + + $result = $context + ->setClientOptions($newOptions) + ->setDataConverter($converter); + + self::assertSame($context, $result); + self::assertSame($newOptions, $context->getClientOptions()); + self::assertSame($converter, $context->getDataConverter()); + } +} diff --git a/tests/Unit/Plugin/ScheduleClientPluginTestCase.php b/tests/Unit/Plugin/ScheduleClientPluginTestCase.php new file mode 100644 index 000000000..2e6ce1f2d --- /dev/null +++ b/tests/Unit/Plugin/ScheduleClientPluginTestCase.php @@ -0,0 +1,193 @@ +called = true; + } + }; + + new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertTrue($called); + } + + public function testPluginModifiesClientOptions(): void + { + $plugin = new class implements ScheduleClientPluginInterface { + use ScheduleClientPluginTrait; + + public function getName(): string + { + return 'test.namespace'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context): void + { + $context->setClientOptions( + (new ClientOptions())->withNamespace('schedule-namespace'), + ); + } + }; + + $client = new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + self::assertNotNull($client); + } + + public function testPluginModifiesDataConverter(): void + { + $customConverter = $this->createMock(DataConverterInterface::class); + + $plugin = new class($customConverter) implements ScheduleClientPluginInterface { + use ScheduleClientPluginTrait; + + public function __construct(private DataConverterInterface $converter) {} + + public function getName(): string + { + return 'test.converter'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context): void + { + $context->setDataConverter($this->converter); + } + }; + + $client = new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + self::assertNotNull($client); + } + + public function testMultiplePluginsCalledInOrder(): void + { + $order = []; + + $plugin1 = new class($order) implements ScheduleClientPluginInterface { + use ScheduleClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.first'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context): void + { + $this->order[] = 'first'; + } + }; + + $plugin2 = new class($order) implements ScheduleClientPluginInterface { + use ScheduleClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.second'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context): void + { + $this->order[] = 'second'; + } + }; + + new ScheduleClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + + self::assertSame(['first', 'second'], $order); + } + + public function testNoPluginsDoesNotBreak(): void + { + $client = new ScheduleClient($this->mockServiceClient()); + self::assertNotNull($client); + } + + public function testPluginReceivesCorrectInitialContext(): void + { + $initialOptions = (new ClientOptions())->withNamespace('initial-ns'); + $initialConverter = $this->createMock(DataConverterInterface::class); + + $receivedOptions = null; + $receivedConverter = null; + + $plugin = new class($receivedOptions, $receivedConverter) implements ScheduleClientPluginInterface { + use ScheduleClientPluginTrait; + + public function __construct( + private ?ClientOptions &$receivedOptions, + private ?DataConverterInterface &$receivedConverter, + ) {} + + public function getName(): string + { + return 'test.inspector'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context): void + { + $this->receivedOptions = $context->getClientOptions(); + $this->receivedConverter = $context->getDataConverter(); + } + }; + + new ScheduleClient( + $this->mockServiceClient(), + options: $initialOptions, + converter: $initialConverter, + plugins: [$plugin], + ); + + self::assertSame($initialOptions, $receivedOptions); + self::assertSame($initialConverter, $receivedConverter); + } + + private function mockServiceClient(): ServiceClientInterface + { + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturn($context); + + $client = $this->createMock(ServiceClientInterface::class); + $client->method('getContext')->willReturn($context); + $client->method('withContext')->willReturn($client); + + return $client; + } +} diff --git a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php new file mode 100644 index 000000000..7dda8a896 --- /dev/null +++ b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php @@ -0,0 +1,371 @@ +called = true; + } + }; + + new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + self::assertTrue($called); + } + + public function testConfigureWorkerFactoryModifiesDataConverter(): void + { + $customConverter = $this->createMock(DataConverterInterface::class); + + $plugin = new class($customConverter) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private DataConverterInterface $converter) {} + + public function getName(): string + { + return 'test.converter'; + } + + public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + { + $context->setDataConverter($this->converter); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + self::assertSame($customConverter, $factory->getDataConverter()); + } + + public function testConfigureWorkerIsCalled(): void + { + $called = false; + $receivedTaskQueue = null; + + $plugin = new class($called, $receivedTaskQueue) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct( + private bool &$called, + private ?string &$receivedTaskQueue, + ) {} + + public function getName(): string + { + return 'test.spy'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->called = true; + $this->receivedTaskQueue = $context->getTaskQueue(); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + $factory->newWorker('my-queue'); + + self::assertTrue($called); + self::assertSame('my-queue', $receivedTaskQueue); + } + + public function testConfigureWorkerModifiesWorkerOptions(): void + { + $customOptions = WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(42); + + $plugin = new class($customOptions) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private WorkerOptions $opts) {} + + public function getName(): string + { + return 'test.options'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $context->setWorkerOptions($this->opts); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + $worker = $factory->newWorker('test-queue'); + + self::assertSame(42, $worker->getOptions()->maxConcurrentActivityExecutionSize); + } + + public function testInitializeWorkerIsCalled(): void + { + $receivedWorker = null; + + $plugin = new class($receivedWorker) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private ?WorkerInterface &$receivedWorker) {} + + public function getName(): string + { + return 'test.init'; + } + + public function initializeWorker(WorkerInterface $worker): void + { + $this->receivedWorker = $worker; + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + $worker = $factory->newWorker('test-queue'); + + self::assertSame($worker, $receivedWorker); + } + + public function testInitializeWorkerReceivesCorrectTaskQueue(): void + { + $receivedTaskQueue = null; + + $plugin = new class($receivedTaskQueue) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private ?string &$receivedTaskQueue) {} + + public function getName(): string + { + return 'test.tq'; + } + + public function initializeWorker(WorkerInterface $worker): void + { + $this->receivedTaskQueue = $worker->getID(); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + $factory->newWorker('my-task-queue'); + + self::assertSame('my-task-queue', $receivedTaskQueue); + } + + public function testPluginHookOrder(): void + { + $order = []; + + $plugin = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.order'; + } + + public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + { + $this->order[] = 'configureWorkerFactory'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'configureWorker'; + } + + public function initializeWorker(WorkerInterface $worker): void + { + $this->order[] = 'initializeWorker'; + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + self::assertSame(['configureWorkerFactory'], $order); + + $factory->newWorker(); + + self::assertSame([ + 'configureWorkerFactory', + 'configureWorker', + 'initializeWorker', + ], $order); + } + + public function testMultiplePluginsCalledInOrder(): void + { + $order = []; + + $plugin1 = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.first'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'first'; + } + }; + + $plugin2 = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.second'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'second'; + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin1, $plugin2], + ); + $factory->newWorker(); + + self::assertSame(['first', 'second'], $order); + } + + public function testConfigureWorkerCalledPerWorker(): void + { + $taskQueues = []; + + $plugin = new class($taskQueues) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$taskQueues) {} + + public function getName(): string + { + return 'test.per-worker'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->taskQueues[] = $context->getTaskQueue(); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + $factory->newWorker('queue-a'); + $factory->newWorker('queue-b'); + + self::assertSame(['queue-a', 'queue-b'], $taskQueues); + } + + public function testGetWorkerPluginsReturnsRegistered(): void + { + $plugin1 = new class('p1') extends AbstractPlugin {}; + $plugin2 = new class('p2') extends AbstractPlugin {}; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin1, $plugin2], + ); + + $plugins = $factory->getWorkerPlugins(); + self::assertCount(2, $plugins); + self::assertSame($plugin1, $plugins[0]); + self::assertSame($plugin2, $plugins[1]); + } + + public function testDuplicatePluginThrowsException(): void + { + $plugin1 = new class('dup') extends AbstractPlugin {}; + $plugin2 = new class('dup') extends AbstractPlugin {}; + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Duplicate plugin "dup"'); + + new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin1, $plugin2], + ); + } + + private function mockRpc(): RPCConnectionInterface + { + return $this->createMock(RPCConnectionInterface::class); + } +} diff --git a/tests/Unit/Plugin/WorkerPluginContextTestCase.php b/tests/Unit/Plugin/WorkerPluginContextTestCase.php new file mode 100644 index 000000000..22cd5709b --- /dev/null +++ b/tests/Unit/Plugin/WorkerPluginContextTestCase.php @@ -0,0 +1,62 @@ +getDataConverter()); + } + + public function testFactoryContextSetters(): void + { + $context = new WorkerFactoryPluginContext(); + $converter = $this->createMock(DataConverterInterface::class); + + $result = $context->setDataConverter($converter); + + self::assertSame($context, $result); + self::assertSame($converter, $context->getDataConverter()); + } + + // --- WorkerPluginContext --- + + public function testWorkerContextBuilderPattern(): void + { + $options = WorkerOptions::new(); + $context = new WorkerPluginContext('test-queue', $options); + + self::assertSame('test-queue', $context->getTaskQueue()); + self::assertSame($options, $context->getWorkerOptions()); + self::assertNull($context->getExceptionInterceptor()); + self::assertSame([], $context->getInterceptors()); + } + + public function testWorkerContextSetWorkerOptions(): void + { + $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); + $newOptions = WorkerOptions::new(); + + $result = $context->setWorkerOptions($newOptions); + + self::assertSame($context, $result); + self::assertSame($newOptions, $context->getWorkerOptions()); + } +} From a5147afa9ac24c460d6a83cb37e73f5e31440571 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 2 Mar 2026 19:40:21 +0400 Subject: [PATCH 02/23] feat: implement worker run plugin handle --- src/Plugin/WorkerPluginInterface.php | 25 ++ src/Plugin/WorkerPluginTrait.php | 5 + src/WorkerFactory.php | 22 +- .../Plugin/WorkerFactoryPluginTestCase.php | 306 ++++++++++++++++++ 4 files changed, 351 insertions(+), 7 deletions(-) diff --git a/src/Plugin/WorkerPluginInterface.php b/src/Plugin/WorkerPluginInterface.php index 1540c96c2..d6dddc3ee 100644 --- a/src/Plugin/WorkerPluginInterface.php +++ b/src/Plugin/WorkerPluginInterface.php @@ -52,4 +52,29 @@ public function configureWorker(WorkerPluginContext $context): void; * Task queue name is available via {@see WorkerInterface::getID()}. */ public function initializeWorker(WorkerInterface $worker): void; + + /** + * Wraps the worker factory run lifecycle using chain-of-responsibility. + * + * Each plugin wraps the next one in the chain. The innermost call + * executes the actual processing loop. Plugins are chained in + * registration order: the first registered plugin is the outermost wrapper. + * + * Use this to manage resources, add observability, or handle errors: + * + * ```php + * public function run(WorkerFactoryInterface $factory, callable $next): int + * { + * $pool = new ConnectionPool(); + * try { + * return $next($factory); + * } finally { + * $pool->close(); + * } + * } + * ``` + * + * @param callable(WorkerFactoryInterface): int $next Calls the next plugin or the actual run loop. + */ + public function run(WorkerFactoryInterface $factory, callable $next): int; } diff --git a/src/Plugin/WorkerPluginTrait.php b/src/Plugin/WorkerPluginTrait.php index 1bf995577..a2750f4f6 100644 --- a/src/Plugin/WorkerPluginTrait.php +++ b/src/Plugin/WorkerPluginTrait.php @@ -35,4 +35,9 @@ public function initializeWorker(WorkerInterface $worker): void { // No-op by default } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + return $next($factory); + } } diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index 0efbe2b47..d4563bb91 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -27,6 +27,7 @@ use Temporal\Interceptor\PipelineProvider; use Temporal\Interceptor\SimplePipelineProvider; use Temporal\Internal\Events\EventEmitterTrait; +use Temporal\Internal\Interceptor\Pipeline; use Temporal\Plugin\CompositePipelineProvider; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerFactoryPluginContext; @@ -257,15 +258,22 @@ public function run(?HostConnectionInterface $host = null): int $host ??= RoadRunner::create(); $this->codec = $this->createCodec(); - while ($msg = $host->waitBatch()) { - try { - $host->send($this->dispatch($msg->messages, $msg->context)); - } catch (\Throwable $e) { - $host->error($e); + $pipeline = Pipeline::prepare( + $this->pluginRegistry->getPlugins(WorkerPluginInterface::class), + ); + + return $pipeline->with(function () use ($host): int { + while ($msg = $host->waitBatch()) { + try { + $host->send($this->dispatch($msg->messages, $msg->context)); + } catch (\Throwable $e) { + $host->error($e); + } } - } - return 0; + return 0; + /** @see WorkerPluginInterface::run() */ + }, 'run')($this); } public function tick(): void diff --git a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php index 7dda8a896..6f9e56651 100644 --- a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php +++ b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php @@ -12,8 +12,10 @@ use Temporal\Plugin\WorkerPluginContext; use Temporal\Plugin\WorkerPluginInterface; use Temporal\Plugin\WorkerPluginTrait; +use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; use Temporal\Worker\WorkerOptions; +use Temporal\Worker\Transport\HostConnectionInterface; use Temporal\Worker\Transport\RPCConnectionInterface; use Temporal\WorkerFactory; @@ -364,8 +366,312 @@ public function testDuplicatePluginThrowsException(): void ); } + public function testRunHookIsCalled(): void + { + $called = false; + $plugin = new class($called) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private bool &$called) {} + + public function getName(): string + { + return 'test.run'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + $this->called = true; + return $next($factory); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + $factory->run($this->mockHost()); + + self::assertTrue($called); + } + + public function testRunHookReceivesFactoryInstance(): void + { + $receivedFactory = null; + + $plugin = new class($receivedFactory) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private ?WorkerFactoryInterface &$receivedFactory) {} + + public function getName(): string + { + return 'test.factory-ref'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + $this->receivedFactory = $factory; + return $next($factory); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + $factory->run($this->mockHost()); + + self::assertSame($factory, $receivedFactory); + } + + public function testRunHookChainOrder(): void + { + $order = []; + + $plugin1 = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.first'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + $this->order[] = 'first:before'; + try { + return $next($factory); + } finally { + $this->order[] = 'first:after'; + } + } + }; + + $plugin2 = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.second'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + $this->order[] = 'second:before'; + try { + return $next($factory); + } finally { + $this->order[] = 'second:after'; + } + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin1, $plugin2], + ); + + $factory->run($this->mockHost()); + + // First plugin is outermost: before in forward order, after in reverse (LIFO) + self::assertSame([ + 'first:before', + 'second:before', + 'second:after', + 'first:after', + ], $order); + } + + public function testRunHookCanWrapWithTryFinally(): void + { + $cleanupCalled = false; + + $plugin = new class($cleanupCalled) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private bool &$cleanupCalled) {} + + public function getName(): string + { + return 'test.cleanup'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + try { + return $next($factory); + } finally { + $this->cleanupCalled = true; + } + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + $factory->run($this->mockHost()); + + self::assertTrue($cleanupCalled); + } + + public function testRunHookCanSkipNext(): void + { + $innerCalled = false; + + $outerPlugin = new class() implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function getName(): string + { + return 'test.outer'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + // Intentionally skip $next() + return 42; + } + }; + + $innerPlugin = new class($innerCalled) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private bool &$innerCalled) {} + + public function getName(): string + { + return 'test.inner'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + $this->innerCalled = true; + return $next($factory); + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$outerPlugin, $innerPlugin], + ); + + $result = $factory->run($this->mockHost()); + + self::assertSame(42, $result); + self::assertFalse($innerCalled); + } + + public function testRunHookFullLifecycleOrder(): void + { + $order = []; + + $plugin = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.lifecycle'; + } + + public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + { + $this->order[] = 'configureWorkerFactory'; + } + + public function configureWorker(WorkerPluginContext $context): void + { + $this->order[] = 'configureWorker'; + } + + public function initializeWorker(WorkerInterface $worker): void + { + $this->order[] = 'initializeWorker'; + } + + public function run(WorkerFactoryInterface $factory, callable $next): int + { + $this->order[] = 'run:before'; + try { + return $next($factory); + } finally { + $this->order[] = 'run:after'; + } + } + }; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + $factory->newWorker(); + $factory->run($this->mockHost()); + + self::assertSame([ + 'configureWorkerFactory', + 'configureWorker', + 'initializeWorker', + 'run:before', + 'run:after', + ], $order); + } + + public function testRunHookReturnsValueFromRunLoop(): void + { + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + ); + + $result = $factory->run($this->mockHost()); + + self::assertSame(0, $result); + } + + public function testDefaultTraitRunPassesThrough(): void + { + $plugin = new class('test.noop') extends AbstractPlugin {}; + + $factory = new WorkerFactory( + DataConverter::createDefault(), + $this->mockRpc(), + plugins: [$plugin], + ); + + $result = $factory->run($this->mockHost()); + + self::assertSame(0, $result); + } + private function mockRpc(): RPCConnectionInterface { return $this->createMock(RPCConnectionInterface::class); } + + /** + * Create a mock host that immediately returns null (empty run loop). + */ + private function mockHost(): HostConnectionInterface + { + $host = $this->createMock(HostConnectionInterface::class); + $host->method('waitBatch')->willReturn(null); + + return $host; + } } From 25aa972e8e3984f8159754817bd3f69c0f49c2fa Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 2 Mar 2026 20:06:02 +0400 Subject: [PATCH 03/23] feat: add connection plugin interface and context for configuring service client connection --- src/Client/ScheduleClient.php | 12 +- src/Client/WorkflowClient.php | 9 + src/Plugin/AbstractPlugin.php | 1 + src/Plugin/ConnectionPluginContext.php | 39 +++ src/Plugin/ConnectionPluginInterface.php | 36 +++ src/Plugin/ConnectionPluginTrait.php | 25 ++ src/Plugin/PluginRegistry.php | 4 +- src/Plugin/TemporalPluginInterface.php | 8 +- .../ConnectionPluginContextTestCase.php | 48 +++ .../Unit/Plugin/ConnectionPluginTestCase.php | 306 ++++++++++++++++++ tests/Unit/Plugin/PluginRegistryTestCase.php | 12 +- 11 files changed, 484 insertions(+), 16 deletions(-) create mode 100644 src/Plugin/ConnectionPluginContext.php create mode 100644 src/Plugin/ConnectionPluginInterface.php create mode 100644 src/Plugin/ConnectionPluginTrait.php create mode 100644 tests/Unit/Plugin/ConnectionPluginContextTestCase.php create mode 100644 tests/Unit/Plugin/ConnectionPluginTestCase.php diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index cdf56f134..aa9434dca 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -32,6 +32,8 @@ use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; use Temporal\Internal\Mapper\ScheduleMapper; +use Temporal\Plugin\ConnectionPluginContext; +use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginContext; use Temporal\Plugin\ScheduleClientPluginInterface; @@ -61,9 +63,17 @@ public function __construct( $this->clientOptions = $options ?? new ClientOptions(); $this->converter = $converter ?? DataConverter::createDefault(); - // Apply schedule client plugins + // Apply plugins if ($plugins !== []) { $pluginRegistry = new PluginRegistry($plugins); + + // Apply connection plugins (before client-level configuration) + $connectionContext = new ConnectionPluginContext($serviceClient); + foreach ($pluginRegistry->getPlugins(ConnectionPluginInterface::class) as $plugin) { + $plugin->configureServiceClient($connectionContext); + } + $serviceClient = $connectionContext->getServiceClient(); + $pluginContext = new ScheduleClientPluginContext( clientOptions: $this->clientOptions, dataConverter: $this->converter, diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index 1c70e6f27..431b61248 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -41,6 +41,8 @@ use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; use Temporal\Plugin\CompositePipelineProvider; +use Temporal\Plugin\ConnectionPluginContext; +use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginInterface; use Temporal\Plugin\WorkerPluginInterface; @@ -88,6 +90,13 @@ public function __construct( $this->clientOptions = $options ?? new ClientOptions(); $this->converter = $converter ?? DataConverter::createDefault(); + // Apply connection plugins (before client-level configuration) + $connectionContext = new ConnectionPluginContext($serviceClient); + foreach ($this->pluginRegistry->getPlugins(ConnectionPluginInterface::class) as $plugin) { + $plugin->configureServiceClient($connectionContext); + } + $serviceClient = $connectionContext->getServiceClient(); + $pluginContext = new ClientPluginContext( clientOptions: $this->clientOptions, dataConverter: $this->converter, diff --git a/src/Plugin/AbstractPlugin.php b/src/Plugin/AbstractPlugin.php index 9b1d22ad4..2b2e6d10a 100644 --- a/src/Plugin/AbstractPlugin.php +++ b/src/Plugin/AbstractPlugin.php @@ -18,6 +18,7 @@ */ abstract class AbstractPlugin implements TemporalPluginInterface { + use ConnectionPluginTrait; use ClientPluginTrait; use ScheduleClientPluginTrait; use WorkerPluginTrait; diff --git a/src/Plugin/ConnectionPluginContext.php b/src/Plugin/ConnectionPluginContext.php new file mode 100644 index 000000000..0108e1581 --- /dev/null +++ b/src/Plugin/ConnectionPluginContext.php @@ -0,0 +1,39 @@ +serviceClient; + } + + public function setServiceClient(ServiceClientInterface $serviceClient): self + { + $this->serviceClient = $serviceClient; + return $this; + } +} diff --git a/src/Plugin/ConnectionPluginInterface.php b/src/Plugin/ConnectionPluginInterface.php new file mode 100644 index 000000000..4caa6f7fe --- /dev/null +++ b/src/Plugin/ConnectionPluginInterface.php @@ -0,0 +1,36 @@ +getName(); if (isset($this->plugins[$name])) { diff --git a/src/Plugin/TemporalPluginInterface.php b/src/Plugin/TemporalPluginInterface.php index f76d28c29..f58b24821 100644 --- a/src/Plugin/TemporalPluginInterface.php +++ b/src/Plugin/TemporalPluginInterface.php @@ -12,12 +12,12 @@ namespace Temporal\Plugin; /** - * Combined plugin interface for bundles that configure client, schedule client, and worker. + * Combined plugin interface for bundles that configure connection, client, schedule client, and worker. * * Implementing this interface is optional — plugins may implement only - * {@see ClientPluginInterface}, {@see ScheduleClientPluginInterface}, - * or {@see WorkerPluginInterface} as needed. + * {@see ConnectionPluginInterface}, {@see ClientPluginInterface}, + * {@see ScheduleClientPluginInterface}, or {@see WorkerPluginInterface} as needed. */ -interface TemporalPluginInterface extends ClientPluginInterface, ScheduleClientPluginInterface, WorkerPluginInterface +interface TemporalPluginInterface extends ConnectionPluginInterface, ClientPluginInterface, ScheduleClientPluginInterface, WorkerPluginInterface { } diff --git a/tests/Unit/Plugin/ConnectionPluginContextTestCase.php b/tests/Unit/Plugin/ConnectionPluginContextTestCase.php new file mode 100644 index 000000000..f52c8443f --- /dev/null +++ b/tests/Unit/Plugin/ConnectionPluginContextTestCase.php @@ -0,0 +1,48 @@ +createMock(ServiceClientInterface::class); + $context = new ConnectionPluginContext($serviceClient); + + self::assertSame($serviceClient, $context->getServiceClient()); + } + + public function testSetServiceClientReplacesValue(): void + { + $original = $this->createMock(ServiceClientInterface::class); + $replacement = $this->createMock(ServiceClientInterface::class); + + $context = new ConnectionPluginContext($original); + $context->setServiceClient($replacement); + + self::assertSame($replacement, $context->getServiceClient()); + } + + public function testSetServiceClientReturnsSelf(): void + { + $context = new ConnectionPluginContext( + $this->createMock(ServiceClientInterface::class), + ); + + $result = $context->setServiceClient( + $this->createMock(ServiceClientInterface::class), + ); + + self::assertSame($context, $result); + } +} diff --git a/tests/Unit/Plugin/ConnectionPluginTestCase.php b/tests/Unit/Plugin/ConnectionPluginTestCase.php new file mode 100644 index 000000000..bcfb6251f --- /dev/null +++ b/tests/Unit/Plugin/ConnectionPluginTestCase.php @@ -0,0 +1,306 @@ +called = true; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertTrue($called); + } + + public function testConfigureServiceClientCalledFromScheduleClient(): void + { + $called = false; + $plugin = new class($called) implements ConnectionPluginInterface, ScheduleClientPluginInterface { + use ConnectionPluginTrait; + use ScheduleClientPluginTrait; + + public function __construct(private bool &$called) {} + + public function getName(): string + { + return 'test.connection'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $this->called = true; + } + }; + + new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertTrue($called); + } + + public function testPluginModifiesServiceClientViaWithAuthKey(): void + { + $authedClient = $this->mockServiceClient(); + + $originalClient = $this->createMock(ServiceClientInterface::class); + $originalClient->method('withAuthKey')->willReturn($authedClient); + // Allow context calls for the client constructor + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturn($context); + $originalClient->method('getContext')->willReturn($context); + $originalClient->method('withContext')->willReturn($originalClient); + + $plugin = new class implements ConnectionPluginInterface, ClientPluginInterface { + use ConnectionPluginTrait; + use ClientPluginTrait; + + public function getName(): string + { + return 'test.auth'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $context->setServiceClient( + $context->getServiceClient()->withAuthKey('my-api-key'), + ); + } + }; + + $client = new WorkflowClient($originalClient, plugins: [$plugin]); + + // The service client should be the authed version + self::assertSame($authedClient, $client->getServiceClient()); + } + + public function testPluginAddsMetadataViaContext(): void + { + $metadataSet = null; + + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturnCallback( + static function (array $metadata) use ($context, &$metadataSet) { + $metadataSet = $metadata; + return $context; + }, + ); + + $serviceClient = $this->createMock(ServiceClientInterface::class); + $serviceClient->method('getContext')->willReturn($context); + $serviceClient->method('withContext')->willReturn($serviceClient); + + $plugin = new class implements ConnectionPluginInterface, ClientPluginInterface { + use ConnectionPluginTrait; + use ClientPluginTrait; + + public function getName(): string + { + return 'test.metadata'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $client = $context->getServiceClient(); + $ctx = $client->getContext(); + $context->setServiceClient( + $client->withContext( + $ctx->withMetadata(['x-custom-header' => ['value']] + $ctx->getMetadata()), + ), + ); + } + }; + + new WorkflowClient($serviceClient, plugins: [$plugin]); + + // Metadata should have been set (by plugin and then by WorkflowClient for namespace) + self::assertNotNull($metadataSet); + } + + public function testMultipleConnectionPluginsCalledInOrder(): void + { + $order = []; + + $plugin1 = new class($order) implements ConnectionPluginInterface, ClientPluginInterface { + use ConnectionPluginTrait; + use ClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.first'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $this->order[] = 'first'; + } + }; + + $plugin2 = new class($order) implements ConnectionPluginInterface, ClientPluginInterface { + use ConnectionPluginTrait; + use ClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.second'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $this->order[] = 'second'; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + + self::assertSame(['first', 'second'], $order); + } + + public function testConnectionPluginRunsBeforeClientPlugin(): void + { + $order = []; + + $plugin = new class($order) implements ConnectionPluginInterface, ClientPluginInterface { + use ConnectionPluginTrait; + use ClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.order'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $this->order[] = 'connection'; + } + + public function configureClient(ClientPluginContext $context): void + { + $this->order[] = 'client'; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertSame(['connection', 'client'], $order); + } + + public function testDefaultTraitIsNoOp(): void + { + $plugin = new class('test.noop') extends AbstractPlugin {}; + + // Should not throw — all trait methods are no-ops + $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + self::assertNotNull($client); + } + + public function testAbstractPluginWorksWithConnectionPlugin(): void + { + $called = false; + + $plugin = new class($called) extends AbstractPlugin { + private bool $ref; + + public function __construct(bool &$called) + { + parent::__construct('test.abstract'); + $this->ref = &$called; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $this->ref = true; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertTrue($called); + } + + public function testConnectionOnlyPluginNotRegisteredAsClientPlugin(): void + { + // A plugin implementing only ConnectionPluginInterface + // should still work when passed to WorkflowClient + $called = false; + $plugin = new class($called) implements ConnectionPluginInterface { + use ConnectionPluginTrait; + + public function __construct(private bool &$called) {} + + public function getName(): string + { + return 'test.conn-only'; + } + + public function configureServiceClient(ConnectionPluginContext $context): void + { + $this->called = true; + } + }; + + new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + + self::assertTrue($called); + } + + private function mockServiceClient(): ServiceClientInterface + { + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturn($context); + + $client = $this->createMock(ServiceClientInterface::class); + $client->method('getContext')->willReturn($context); + $client->method('withContext')->willReturn($client); + $client->method('withAuthKey')->willReturn($client); + + return $client; + } +} diff --git a/tests/Unit/Plugin/PluginRegistryTestCase.php b/tests/Unit/Plugin/PluginRegistryTestCase.php index bddec2f5b..b8dde7e79 100644 --- a/tests/Unit/Plugin/PluginRegistryTestCase.php +++ b/tests/Unit/Plugin/PluginRegistryTestCase.php @@ -10,10 +10,8 @@ use Temporal\Plugin\ClientPluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginInterface; -use Temporal\Plugin\WorkerFactoryPluginContext; -use Temporal\Plugin\WorkerPluginContext; use Temporal\Plugin\WorkerPluginInterface; -use Temporal\Worker\WorkerInterface; +use Temporal\Plugin\WorkerPluginTrait; /** * @group unit @@ -84,16 +82,12 @@ public function configureClient(ClientPluginContext $context): void {} }; $workerPlugin = new class implements WorkerPluginInterface { + use WorkerPluginTrait; + public function getName(): string { return 'worker-only'; } - - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void {} - - public function configureWorker(WorkerPluginContext $context): void {} - - public function initializeWorker(WorkerInterface $worker): void {} }; $bothPlugin = $this->createPlugin('both'); From 0e38b045289cbc1ca0819a1df66d24fe99280dc1 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Mon, 2 Mar 2026 20:57:16 +0400 Subject: [PATCH 04/23] feat: pass plugin names to GetWorkerInfo for observability --- src/Internal/Transport/Router/GetWorkerInfo.php | 5 +++++ src/WorkerFactory.php | 7 ++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/Internal/Transport/Router/GetWorkerInfo.php b/src/Internal/Transport/Router/GetWorkerInfo.php index 915141de7..cb008b291 100644 --- a/src/Internal/Transport/Router/GetWorkerInfo.php +++ b/src/Internal/Transport/Router/GetWorkerInfo.php @@ -24,10 +24,14 @@ final class GetWorkerInfo extends Route { + /** + * @param list $pluginNames Names of registered plugins for observability. + */ public function __construct( private readonly RepositoryInterface $queues, private readonly MarshallerInterface $marshaller, private readonly ServiceCredentials $credentials, + private readonly array $pluginNames = [], ) {} public function handle(ServerRequestInterface $request, array $headers, Deferred $resolver): void @@ -62,6 +66,7 @@ private function workerToArray(WorkerInterface $worker): array // ActivityInfo[] 'Activities' => $this->map($worker->getActivities(), $activityMap), 'PhpSdkVersion' => SdkVersion::getSdkVersion(), + 'Plugins' => $this->pluginNames, 'Flags' => (object) $this->prepareFlags(), ]; } diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index d4563bb91..aff352312 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -304,8 +304,13 @@ protected function createTaskQueue(): RepositoryInterface protected function createRouter(ServiceCredentials $credentials): RouterInterface { + $pluginNames = \array_map( + static fn(WorkerPluginInterface $p): string => $p->getName(), + $this->pluginRegistry->getPlugins(WorkerPluginInterface::class), + ); + $router = new Router(); - $router->add(new Router\GetWorkerInfo($this->queues, $this->marshaller, $credentials)); + $router->add(new Router\GetWorkerInfo($this->queues, $this->marshaller, $credentials, $pluginNames)); return $router; } From 0592d23d4db505c569fc21c82e3aaa3946bb9b5e Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 6 Mar 2026 09:33:42 +0400 Subject: [PATCH 05/23] feat: allow to use external server --- testing/src/Environment.php | 90 +++++++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 28 deletions(-) diff --git a/testing/src/Environment.php b/testing/src/Environment.php index 2fa8119ee..6260f473a 100644 --- a/testing/src/Environment.php +++ b/testing/src/Environment.php @@ -21,12 +21,14 @@ final class Environment private ?Process $temporalTestServerProcess = null; private ?Process $temporalServerProcess = null; private ?Process $roadRunnerProcess = null; + private bool $externalTemporalProcessActive = false; public function __construct( OutputInterface $output, private Downloader $downloader, private SystemInfo $systemInfo, ?Command $command = null, + private bool $allowExternalTemporalProcess = false, ) { $this->io = $output instanceof SymfonyStyle ? $output @@ -37,6 +39,7 @@ public function __construct( public static function create(?Command $command = null): self { $token = \getenv('GITHUB_TOKEN'); + $allowExternalTemporalProcess = \getenv('ALLOW_EXTERNAL_TEMPORAL_PROCESS') === 'true'; $systemInfo = SystemInfo::detect(); \is_string(\getenv('ROADRUNNER_BINARY')) and $systemInfo->rrExecutable = \getenv('ROADRUNNER_BINARY'); @@ -50,6 +53,7 @@ public static function create(?Command $command = null): self ])), $systemInfo, $command, + $allowExternalTemporalProcess, ); } @@ -139,17 +143,23 @@ public function startTemporalServer( } if (!$temporalStarted || !$this->temporalServerProcess->isRunning()) { - $this->io->error([ - \sprintf( - 'Error starting Temporal server: %s.', - !$temporalStarted ? "Health check failed" : $this->temporalServerProcess->getErrorOutput(), - ), - \sprintf( - 'Command: `%s`.', - $this->serializeProcess($this->temporalServerProcess), - ), - ]); - exit(1); + $errorOutput = $this->temporalServerProcess->getErrorOutput(); + if (!$this->allowExternalTemporalProcess || !\str_contains($errorOutput, 'address already in use')) { + $this->io->error([ + \sprintf( + 'Error starting Temporal server: %s.', + !$temporalStarted ? "Health check failed" : $errorOutput, + ), + \sprintf( + 'Command: `%s`.', + $this->serializeProcess($this->temporalServerProcess), + ), + ]); + exit(1); + } + $this->io->warning('Using external Temporal Server'); + + $this->externalTemporalProcessActive = true; } $this->io->info('Temporal server started.'); } @@ -174,17 +184,21 @@ public function startTemporalTestServer(int $commandTimeout = 10): void \sleep(1); if (!$this->temporalTestServerProcess->isRunning()) { - $this->io->error([ - \sprintf( - 'Error starting Temporal Test server: %s.', - $this->temporalTestServerProcess->getErrorOutput(), - ), - \sprintf( - 'Command: `%s`.', - $this->serializeProcess($this->temporalTestServerProcess), - ), - ]); - exit(1); + $errorOutput = $this->temporalTestServerProcess->getErrorOutput(); + if (!$this->allowExternalTemporalProcess || !\str_contains($errorOutput, 'address already in use')) { + $this->io->error([ + \sprintf( + 'Error starting Temporal Test server: %s.', + $errorOutput, + ), + \sprintf( + 'Command: `%s`.', + $this->serializeProcess($this->temporalTestServerProcess), + ), + ]); + exit(1); + } + $this->io->warning('Using external Temporal Test Server'); } $this->io->info('Temporal Test server started.'); } @@ -263,8 +277,7 @@ public function stopTemporalServer(): void { if ($this->isTemporalRunning()) { $this->io->info('Stopping Temporal server... '); - $this->temporalServerProcess->stop(); - $this->temporalServerProcess = null; + $this->stopTemporalServerProcess(); $this->io->info('Temporal server stopped.'); } } @@ -273,8 +286,7 @@ public function stopTemporalTestServer(): void { if ($this->isTemporalTestRunning()) { $this->io->info('Stopping Temporal Test server... '); - $this->temporalTestServerProcess->stop(); - $this->temporalTestServerProcess = null; + $this->stopTemporalTestServerProcess(); $this->io->info('Temporal Test server stopped.'); } } @@ -291,7 +303,8 @@ public function stopRoadRunner(): void public function isTemporalRunning(): bool { - return $this->temporalServerProcess?->isRunning() === true; + return ($this->allowExternalTemporalProcess && $this->externalTemporalProcessActive) || + $this->temporalServerProcess?->isRunning() === true; } public function isRoadRunnerRunning(): bool @@ -301,7 +314,28 @@ public function isRoadRunnerRunning(): bool public function isTemporalTestRunning(): bool { - return $this->temporalTestServerProcess?->isRunning() === true; + return ($this->allowExternalTemporalProcess && $this->externalTemporalProcessActive) || + $this->temporalTestServerProcess?->isRunning() === true; + } + + private function stopTemporalTestServerProcess(): void + { + if ($this->externalTemporalProcessActive) { + $this->externalTemporalProcessActive = false; + return; + } + $this->temporalTestServerProcess->stop(); + $this->temporalTestServerProcess = null; + } + + private function stopTemporalServerProcess(): void + { + if ($this->externalTemporalProcessActive) { + $this->externalTemporalProcessActive = false; + return; + } + $this->temporalServerProcess->stop(); + $this->temporalServerProcess = null; } private function serializeProcess(?Process $temporalServerProcess): string|array From cf586a5e93c35095db59e08ce691787412955c07 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Fri, 6 Mar 2026 10:22:00 +0400 Subject: [PATCH 06/23] feat: create separate interface --- src/Plugin/ClientPluginInterface.php | 8 +------- src/Plugin/PluginInterface.php | 21 ++++++++++++++++++++ src/Plugin/ScheduleClientPluginInterface.php | 8 +------- src/Plugin/WorkerPluginInterface.php | 8 +------- 4 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 src/Plugin/PluginInterface.php diff --git a/src/Plugin/ClientPluginInterface.php b/src/Plugin/ClientPluginInterface.php index e32d2a186..c7eb36878 100644 --- a/src/Plugin/ClientPluginInterface.php +++ b/src/Plugin/ClientPluginInterface.php @@ -19,14 +19,8 @@ * * Configuration methods are called in registration order (first registered = first called). */ -interface ClientPluginInterface +interface ClientPluginInterface extends PluginInterface { - /** - * Unique name identifying this plugin (e.g., "my-org.tracing"). - * Used for deduplication and diagnostics. - */ - public function getName(): string; - /** * Modify client configuration before the client is created. * diff --git a/src/Plugin/PluginInterface.php b/src/Plugin/PluginInterface.php new file mode 100644 index 000000000..95176436a --- /dev/null +++ b/src/Plugin/PluginInterface.php @@ -0,0 +1,21 @@ + Date: Fri, 6 Mar 2026 10:53:20 +0400 Subject: [PATCH 07/23] feat: pass registry instead of arrays, adjust tests --- src/Client/ScheduleClient.php | 49 +++++++++---------- src/Client/WorkflowClient.php | 9 ++-- .../Transport/Router/GetWorkerInfo.php | 13 +++-- src/Worker/WorkerFactoryInterface.php | 7 ++- src/WorkerFactory.php | 32 +++++------- testing/src/WorkerFactory.php | 9 ++-- tests/Acceptance/App/Attribute/Worker.php | 3 ++ .../Acceptance/App/Feature/WorkerFactory.php | 15 +++--- tests/Acceptance/App/TestCase.php | 39 ++++++++++++--- .../Extra/Plugin/ClientPluginTest.php | 19 ++++--- tests/Unit/Framework/WorkerFactoryMock.php | 24 +++++---- tests/Unit/Plugin/ClientPluginTestCase.php | 21 ++++---- .../Unit/Plugin/ConnectionPluginTestCase.php | 19 +++---- .../Unit/Plugin/PluginPropagationTestCase.php | 18 ++++--- .../Plugin/ScheduleClientPluginTestCase.php | 11 +++-- .../Plugin/WorkerFactoryPluginTestCase.php | 40 ++++++++------- 16 files changed, 179 insertions(+), 149 deletions(-) diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index aa9434dca..4c06eb13e 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -50,6 +50,7 @@ final class ScheduleClient implements ScheduleClientInterface private DataConverterInterface $converter; private MarshallerInterface $marshaller; private ProtoToArrayConverter $protoConverter; + private PluginRegistry $pluginRegistry; /** * @param list $plugins @@ -58,34 +59,31 @@ public function __construct( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, ?DataConverterInterface $converter = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ) { $this->clientOptions = $options ?? new ClientOptions(); $this->converter = $converter ?? DataConverter::createDefault(); + $this->pluginRegistry = $pluginRegistry ?? new PluginRegistry(); - // Apply plugins - if ($plugins !== []) { - $pluginRegistry = new PluginRegistry($plugins); - - // Apply connection plugins (before client-level configuration) - $connectionContext = new ConnectionPluginContext($serviceClient); - foreach ($pluginRegistry->getPlugins(ConnectionPluginInterface::class) as $plugin) { - $plugin->configureServiceClient($connectionContext); - } - $serviceClient = $connectionContext->getServiceClient(); + // Apply connection plugins (before client-level configuration) + $connectionContext = new ConnectionPluginContext($serviceClient); + foreach ($this->pluginRegistry->getPlugins(ConnectionPluginInterface::class) as $plugin) { + $plugin->configureServiceClient($connectionContext); + } + $serviceClient = $connectionContext->getServiceClient(); - $pluginContext = new ScheduleClientPluginContext( - clientOptions: $this->clientOptions, - dataConverter: $this->converter, - ); - foreach ($pluginRegistry->getPlugins(ScheduleClientPluginInterface::class) as $plugin) { - $plugin->configureScheduleClient($pluginContext); - } - $this->clientOptions = $pluginContext->getClientOptions(); - if ($pluginContext->getDataConverter() !== null) { - $this->converter = $pluginContext->getDataConverter(); - } + $pluginContext = new ScheduleClientPluginContext( + clientOptions: $this->clientOptions, + dataConverter: $this->converter, + ); + foreach ($this->pluginRegistry->getPlugins(ScheduleClientPluginInterface::class) as $plugin) { + $plugin->configureScheduleClient($pluginContext); + } + $this->clientOptions = $pluginContext->getClientOptions(); + if ($pluginContext->getDataConverter() !== null) { + $this->converter = $pluginContext->getDataConverter(); } + $this->marshaller = new Marshaller( new AttributeMapperFactory(new AttributeReader()), ); @@ -100,16 +98,13 @@ public function __construct( ); } - /** - * @param list $plugins - */ public static function create( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, ?DataConverterInterface $converter = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ): ScheduleClientInterface { - return new self($serviceClient, $options, $converter, $plugins); + return new self($serviceClient, $options, $converter, $pluginRegistry); } public function createSchedule( diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index 431b61248..73f7a179f 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -84,9 +84,9 @@ public function __construct( ?ClientOptions $options = null, ?DataConverterInterface $converter = null, ?PipelineProvider $interceptorProvider = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ) { - $this->pluginRegistry = new PluginRegistry($plugins); + $this->pluginRegistry = $pluginRegistry ?? new PluginRegistry(); $this->clientOptions = $options ?? new ClientOptions(); $this->converter = $converter ?? DataConverter::createDefault(); @@ -129,7 +129,6 @@ public function __construct( } /** - * @param list $plugins * @return static */ public static function create( @@ -137,9 +136,9 @@ public static function create( ?ClientOptions $options = null, ?DataConverterInterface $converter = null, ?PipelineProvider $interceptorProvider = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ): self { - return new self($serviceClient, $options, $converter, $interceptorProvider, $plugins); + return new self($serviceClient, $options, $converter, $interceptorProvider, $pluginRegistry); } /** diff --git a/src/Internal/Transport/Router/GetWorkerInfo.php b/src/Internal/Transport/Router/GetWorkerInfo.php index cb008b291..596c214d4 100644 --- a/src/Internal/Transport/Router/GetWorkerInfo.php +++ b/src/Internal/Transport/Router/GetWorkerInfo.php @@ -18,20 +18,19 @@ use Temporal\Internal\Declaration\Prototype\WorkflowPrototype; use Temporal\Internal\Marshaller\MarshallerInterface; use Temporal\Internal\Repository\RepositoryInterface; +use Temporal\Plugin\PluginInterface; +use Temporal\Plugin\PluginRegistry; use Temporal\Worker\ServiceCredentials; use Temporal\Worker\Transport\Command\ServerRequestInterface; use Temporal\Worker\WorkerInterface; final class GetWorkerInfo extends Route { - /** - * @param list $pluginNames Names of registered plugins for observability. - */ public function __construct( private readonly RepositoryInterface $queues, private readonly MarshallerInterface $marshaller, private readonly ServiceCredentials $credentials, - private readonly array $pluginNames = [], + private readonly PluginRegistry $pluginRegistry, ) {} public function handle(ServerRequestInterface $request, array $headers, Deferred $resolver): void @@ -58,6 +57,10 @@ private function workerToArray(WorkerInterface $worker): array 'Name' => $activity->getID(), ]; + $map = $this->map($this->pluginRegistry->getPlugins(PluginInterface::class), static fn(PluginInterface $plugin): array => [ + 'Name' => $plugin->getName(), + 'Version' => null, + ]); return [ 'TaskQueue' => $worker->getID(), 'Options' => $this->marshaller->marshal($worker->getOptions()), @@ -66,7 +69,7 @@ private function workerToArray(WorkerInterface $worker): array // ActivityInfo[] 'Activities' => $this->map($worker->getActivities(), $activityMap), 'PhpSdkVersion' => SdkVersion::getSdkVersion(), - 'Plugins' => $this->pluginNames, + 'Plugins' => $map, 'Flags' => (object) $this->prepareFlags(), ]; } diff --git a/src/Worker/WorkerFactoryInterface.php b/src/Worker/WorkerFactoryInterface.php index 8f18383c3..3b1ef2d87 100644 --- a/src/Worker/WorkerFactoryInterface.php +++ b/src/Worker/WorkerFactoryInterface.php @@ -14,6 +14,7 @@ use Psr\Log\LoggerInterface; use Temporal\Exception\ExceptionInterceptorInterface; use Temporal\Interceptor\PipelineProvider; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerPluginInterface; /** @@ -47,11 +48,9 @@ public function newWorker( ): WorkerInterface; /** - * Get worker plugins registered with this factory. - * - * @return list + * Get the plugin registry for this factory. */ - public function getWorkerPlugins(): array; + public function getPluginRegistry(): PluginRegistry; /** * Start processing workflows and activities processing. diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index aff352312..e2234c413 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -111,21 +111,16 @@ class WorkerFactory implements WorkerFactoryInterface, LoopInterface protected MarshallerInterface $marshaller; protected EnvironmentInterface $env; - protected PluginRegistry $pluginRegistry; - /** - * @param list $plugins Worker plugins to register. - */ public function __construct( DataConverterInterface $dataConverter, protected RPCConnectionInterface $rpc, ?ServiceCredentials $credentials = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, ) { - $this->pluginRegistry = new PluginRegistry($plugins); - + $this->pluginRegistry = $pluginRegistry ?? new PluginRegistry(); // Propagate worker plugins from the client if ($client !== null) { $this->pluginRegistry->merge($client->getWorkerPlugins()); @@ -143,21 +138,18 @@ public function __construct( $this->boot($credentials ?? ServiceCredentials::create()); } - /** - * @param list $plugins Worker plugins to register. - */ public static function create( ?DataConverterInterface $converter = null, ?RPCConnectionInterface $rpc = null, ?ServiceCredentials $credentials = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, ): static { return new static( $converter ?? DataConverter::createDefault(), $rpc ?? Goridge::create(), $credentials, - $plugins, + $pluginRegistry ?? new PluginRegistry(), $client, ); } @@ -215,9 +207,9 @@ public function newWorker( return $worker; } - public function getWorkerPlugins(): array + public function getPluginRegistry(): PluginRegistry { - return $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + return $this->pluginRegistry; } public function getReader(): ReaderInterface @@ -304,13 +296,13 @@ protected function createTaskQueue(): RepositoryInterface protected function createRouter(ServiceCredentials $credentials): RouterInterface { - $pluginNames = \array_map( - static fn(WorkerPluginInterface $p): string => $p->getName(), - $this->pluginRegistry->getPlugins(WorkerPluginInterface::class), - ); - $router = new Router(); - $router->add(new Router\GetWorkerInfo($this->queues, $this->marshaller, $credentials, $pluginNames)); + $router->add(new Router\GetWorkerInfo( + $this->queues, + $this->marshaller, + $credentials, + $this->pluginRegistry, + )); return $router; } diff --git a/testing/src/WorkerFactory.php b/testing/src/WorkerFactory.php index 2e626719e..41691c980 100644 --- a/testing/src/WorkerFactory.php +++ b/testing/src/WorkerFactory.php @@ -16,6 +16,7 @@ use Temporal\Internal\ServiceContainer; use Temporal\Internal\Workflow\Logger; use Temporal\Plugin\CompositePipelineProvider; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerPluginContext; use Temporal\Plugin\WorkerPluginInterface; use Temporal\Worker\ActivityInvocationCache\ActivityInvocationCacheInterface; @@ -36,19 +37,19 @@ public function __construct( RPCConnectionInterface $rpc, ActivityInvocationCacheInterface $activityCache, ?ServiceCredentials $credentials = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, ) { $this->activityCache = $activityCache; - parent::__construct($dataConverter, $rpc, $credentials ?? ServiceCredentials::create(), $plugins, $client); + parent::__construct($dataConverter, $rpc, $credentials ?? ServiceCredentials::create(), $pluginRegistry, $client); } public static function create( ?DataConverterInterface $converter = null, ?RPCConnectionInterface $rpc = null, ?ServiceCredentials $credentials = null, - array $plugins = [], + ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, ?ActivityInvocationCacheInterface $activityCache = null, ): static { @@ -57,7 +58,7 @@ public static function create( $rpc ?? Goridge::create(), $activityCache ?? RoadRunnerActivityInvocationCache::create($converter), $credentials, - $plugins, + $pluginRegistry ?? new PluginRegistry(), $client, ); } diff --git a/tests/Acceptance/App/Attribute/Worker.php b/tests/Acceptance/App/Attribute/Worker.php index 819806921..87e572313 100644 --- a/tests/Acceptance/App/Attribute/Worker.php +++ b/tests/Acceptance/App/Attribute/Worker.php @@ -6,6 +6,7 @@ use Psr\Log\LoggerInterface; use Temporal\Interceptor\PipelineProvider; +use Temporal\Plugin\PluginInterface; use Temporal\Worker\WorkerOptions; /** @@ -22,10 +23,12 @@ final class Worker * @param array|null $options Callable that returns {@see WorkerOptions} * @param array|null $pipelineProvider Callable that returns {@see PipelineProvider} * @param array|null $logger Callable that returns {@see LoggerInterface} + * @param array|null $plugins */ public function __construct( public readonly ?array $options = null, public readonly ?array $pipelineProvider = null, public readonly ?array $logger = null, + public readonly ?array $plugins = null, ) {} } diff --git a/tests/Acceptance/App/Feature/WorkerFactory.php b/tests/Acceptance/App/Feature/WorkerFactory.php index b6c007a71..c2c14f741 100644 --- a/tests/Acceptance/App/Feature/WorkerFactory.php +++ b/tests/Acceptance/App/Feature/WorkerFactory.php @@ -35,16 +35,19 @@ public function createWorker( ...$feature->workflows, ...$feature->activities, ); - if ($attr !== null) { - $attr->options === null or $options = $this->invoker->invoke($attr->options); - $attr->pipelineProvider === null or $interceptorProvider = $this->invoker->invoke($attr->pipelineProvider); - $attr->logger === null or $logger = $this->invoker->invoke($attr->logger); + $options = $attr?->options === null ? null : $this->invoker->invoke($attr->options); + $interceptorProvider = $attr?->pipelineProvider === null ? null : $this->invoker->invoke($attr->pipelineProvider); + $logger = $attr?->logger === null ? null : $this->invoker->invoke($attr->logger); + + // Add plugins from the attribute to the factory's registry (already instantiated, no invoker needed) + if ($attr?->plugins !== null) { + $this->workerFactory->getPluginRegistry()->merge($attr->plugins); } return $this->workerFactory->newWorker( $feature->taskQueue, $options ?? WorkerOptions::new()->withMaxConcurrentActivityExecutionSize(10), - interceptorProvider: $interceptorProvider ?? null, + interceptorProvider: $interceptorProvider, logger: $logger ?? LoggerFactory::createServerLogger($feature->taskQueue), ); } @@ -53,7 +56,7 @@ public function createWorker( * Find {@see Worker} attribute in the classes collection. * If more than one attribute is found, an exception is thrown. */ - private static function findAttribute(string ...$classes): ?Worker + public static function findAttribute(string ...$classes): ?Worker { $classes = \array_unique($classes); /** @var array $found */ diff --git a/tests/Acceptance/App/TestCase.php b/tests/Acceptance/App/TestCase.php index 7f3c726ff..e6f4675a6 100644 --- a/tests/Acceptance/App/TestCase.php +++ b/tests/Acceptance/App/TestCase.php @@ -11,9 +11,15 @@ use Spiral\Core\Scope; use Temporal\Api\Enums\V1\EventType; use Temporal\Api\Failure\V1\Failure; +use Temporal\Client\ClientOptions; +use Temporal\Client\WorkflowClient; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowStubInterface; use Temporal\Exception\TemporalException; +use Temporal\Plugin\ClientPluginInterface; +use Temporal\Plugin\PluginRegistry; +use Temporal\Tests\Acceptance\App\Attribute\Worker; +use Temporal\Tests\Acceptance\App\Feature\WorkerFactory; use Temporal\Tests\Acceptance\App\Logger\ClientLogger; use Temporal\Tests\Acceptance\App\Logger\LoggerFactory; use Temporal\Tests\Acceptance\App\Runtime\ContainerFacade; @@ -45,14 +51,33 @@ protected function runTest(): mixed $logger = LoggerFactory::createClientLogger($feature->taskQueue); $logger->clear(); + // Build scope bindings + $bindings = [ + Feature::class => $feature, + static::class => $this, + State::class => $runtime, + LoggerInterface::class => ClientLogger::class, + ClientLogger::class => $logger, + ]; + + // Auto-inject plugin-configured client from #[Worker(plugins: [...])] attribute + $workerAttr = WorkerFactory::findAttribute(static::class); + if ($workerAttr?->plugins !== null) { + $pluginRegistry = new PluginRegistry($workerAttr->plugins); + $clientPlugins = $pluginRegistry->getPlugins(ClientPluginInterface::class); + if ($clientPlugins !== []) { + $existingClient = $container->get(WorkflowClientInterface::class); + $pluginClient = WorkflowClient::create( + serviceClient: $existingClient->getServiceClient(), + options: (new ClientOptions())->withNamespace($runtime->namespace), + pluginRegistry: new PluginRegistry($workerAttr->plugins), + ); + $bindings[WorkflowClientInterface::class] = $pluginClient; + } + } + return $container->runScope( - new Scope(name: 'feature', bindings: [ - Feature::class => $feature, - static::class => $this, - State::class => $runtime, - LoggerInterface::class => ClientLogger::class, - ClientLogger::class => $logger, - ]), + new Scope(name: 'feature', bindings: $bindings), function (Container $container): mixed { $reflection = new \ReflectionMethod($this, $this->name()); $args = $container->resolveArguments($reflection); diff --git a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php index be287490c..53f460164 100644 --- a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php +++ b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php @@ -17,7 +17,9 @@ use Temporal\Interceptor\WorkflowClientCallsInterceptor; use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; +use Temporal\Plugin\PluginRegistry; use Temporal\Tests\Acceptance\App\Attribute\Stub; +use Temporal\Tests\Acceptance\App\Attribute\Worker; use Temporal\Tests\Acceptance\App\Runtime\Feature; use Temporal\Tests\Acceptance\App\Runtime\State; use Temporal\Tests\Acceptance\App\TestCase; @@ -25,10 +27,13 @@ use Temporal\Workflow\WorkflowInterface; use Temporal\Workflow\WorkflowMethod; +#[Worker( + plugins: [new PrefixPlugin()], +)] class ClientPluginTest extends TestCase { /** - * Plugin adds interceptor that modifies workflow arguments. + * Plugin from #[Worker(plugins: [...])] is auto-injected into the client. */ #[Test] public function pluginInterceptorModifiesArguments( @@ -39,7 +44,7 @@ public function pluginInterceptorModifiesArguments( $pluginClient = WorkflowClient::create( serviceClient: $client->getServiceClient(), options: (new ClientOptions())->withNamespace($runtime->namespace), - plugins: [new PrefixPlugin()], + pluginRegistry: new PluginRegistry([new PrefixPlugin()]), )->withTimeout(5); $stub = $pluginClient->newUntypedWorkflowStub( @@ -64,7 +69,7 @@ public function multiplePluginsApplyInOrder( $pluginClient = WorkflowClient::create( serviceClient: $client->getServiceClient(), options: (new ClientOptions())->withNamespace($runtime->namespace), - plugins: [new PrefixPlugin('A:'), new PrefixPlugin2('B:')], + pluginRegistry: new PluginRegistry([new PrefixPlugin('A:'), new PrefixPlugin2('B:')]), )->withTimeout(5); $stub = $pluginClient->newUntypedWorkflowStub( @@ -92,19 +97,19 @@ public function duplicatePluginThrowsException( WorkflowClient::create( serviceClient: $client->getServiceClient(), options: (new ClientOptions())->withNamespace($runtime->namespace), - plugins: [new PrefixPlugin(), new PrefixPlugin()], + pluginRegistry: new PluginRegistry([new PrefixPlugin(), new PrefixPlugin()]), ); } /** - * Client without plugins works normally. + * Plugin from #[Worker(plugins: [...])] is also applied via #[Stub] attribute. */ #[Test] - public function noPluginsWorkflow( + public function pluginAppliedViaWorkerAttribute( #[Stub('Extra_Plugin_ClientPlugin', args: ['world'])] WorkflowStubInterface $stub, ): void { - self::assertSame('world', $stub->getResult('string')); + self::assertSame('plugin:world', $stub->getResult('string')); } } diff --git a/tests/Unit/Framework/WorkerFactoryMock.php b/tests/Unit/Framework/WorkerFactoryMock.php index 2455dd78e..6006a486f 100644 --- a/tests/Unit/Framework/WorkerFactoryMock.php +++ b/tests/Unit/Framework/WorkerFactoryMock.php @@ -31,6 +31,7 @@ use Temporal\Internal\Transport\RouterInterface; use Temporal\Internal\Transport\Server; use Temporal\Internal\Transport\ServerInterface; +use Temporal\Plugin\PluginRegistry; use Temporal\Internal\Workflow\Logger; use Temporal\Worker\Environment\Environment; use Temporal\Worker\Environment\EnvironmentInterface; @@ -69,6 +70,7 @@ class WorkerFactoryMock implements WorkerFactoryInterface, LoopInterface private QueueInterface $responses; private MarshallerInterface $marshaller; private EnvironmentInterface $env; + private PluginRegistry $pluginRegistry; public function __construct(DataConverterInterface $dataConverter) { @@ -112,9 +114,9 @@ public function newWorker( return $worker; } - public function getWorkerPlugins(): array + public function getPluginRegistry(): PluginRegistry { - return []; + return $this->pluginRegistry; } public function getReader(): ReaderInterface @@ -173,6 +175,7 @@ public function tick(): void private function boot(): void { + $this->pluginRegistry = new PluginRegistry(); $this->reader = $this->createReader(); $this->marshaller = $this->createMarshaller($this->reader); $this->queues = new ArrayRepository(); @@ -192,29 +195,24 @@ private function createReader(): ReaderInterface return new AttributeReader(); } - /** - * @return RouterInterface - */ private function createRouter(): RouterInterface { $router = new Router(); - $router->add(new Router\GetWorkerInfo($this->queues, $this->marshaller, ServiceCredentials::create())); + $router->add(new Router\GetWorkerInfo( + $this->queues, + $this->marshaller, + ServiceCredentials::create(), + new PluginRegistry(), + )); return $router; } - /** - * @return ServerInterface - */ private function createServer(): ServerInterface { return new Server($this->responses, \Closure::fromCallable([$this, 'onRequest'])); } - /** - * @param ReaderInterface $reader - * @return MarshallerInterface - */ private function createMarshaller(ReaderInterface $reader): MarshallerInterface { return new Marshaller(new AttributeMapperFactory($reader)); diff --git a/tests/Unit/Plugin/ClientPluginTestCase.php b/tests/Unit/Plugin/ClientPluginTestCase.php index 4e1b6e096..8af441763 100644 --- a/tests/Unit/Plugin/ClientPluginTestCase.php +++ b/tests/Unit/Plugin/ClientPluginTestCase.php @@ -16,6 +16,7 @@ use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; use Temporal\Plugin\ClientPluginTrait; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerPluginInterface; use Temporal\Plugin\WorkerPluginTrait; @@ -46,7 +47,7 @@ public function configureClient(ClientPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertTrue($called); } @@ -69,7 +70,7 @@ public function configureClient(ClientPluginContext $context): void } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); // The namespace metadata is set from plugin-modified options self::assertNotNull($client->getServiceClient()); @@ -96,7 +97,7 @@ public function configureClient(ClientPluginContext $context): void }; // Should not throw — converter is applied - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertNotNull($client); } @@ -123,7 +124,7 @@ public function configureClient(ClientPluginContext $context): void }; // Should not throw — interceptor pipeline is built with plugin interceptor - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertNotNull($client); } @@ -163,7 +164,7 @@ public function configureClient(ClientPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin1, $plugin2])); self::assertSame(['first', 'second'], $order); } @@ -176,14 +177,14 @@ public function testDuplicatePluginThrowsException(): void $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Duplicate plugin "dup"'); - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin1, $plugin2])); } public function testGetWorkerPluginsPropagation(): void { $plugin = new class('combo') extends AbstractPlugin {}; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); $workerPlugins = $client->getWorkerPlugins(); self::assertCount(1, $workerPlugins); @@ -194,7 +195,7 @@ public function testGetScheduleClientPluginsPropagation(): void { $plugin = new class('combo') extends AbstractPlugin {}; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); $schedulePlugins = $client->getScheduleClientPlugins(); self::assertCount(1, $schedulePlugins); @@ -212,7 +213,7 @@ public function getName(): string } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertCount(0, $client->getWorkerPlugins()); self::assertCount(0, $client->getScheduleClientPlugins()); @@ -230,7 +231,7 @@ public function getName(): string } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertCount(1, $client->getWorkerPlugins()); self::assertCount(0, $client->getScheduleClientPlugins()); diff --git a/tests/Unit/Plugin/ConnectionPluginTestCase.php b/tests/Unit/Plugin/ConnectionPluginTestCase.php index bcfb6251f..a7e19d090 100644 --- a/tests/Unit/Plugin/ConnectionPluginTestCase.php +++ b/tests/Unit/Plugin/ConnectionPluginTestCase.php @@ -16,6 +16,7 @@ use Temporal\Plugin\ConnectionPluginContext; use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\ConnectionPluginTrait; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginInterface; use Temporal\Plugin\ScheduleClientPluginTrait; @@ -47,7 +48,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertTrue($called); } @@ -72,7 +73,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + new ScheduleClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertTrue($called); } @@ -107,7 +108,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - $client = new WorkflowClient($originalClient, plugins: [$plugin]); + $client = new WorkflowClient($originalClient, pluginRegistry: new PluginRegistry([$plugin])); // The service client should be the authed version self::assertSame($authedClient, $client->getServiceClient()); @@ -151,7 +152,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - new WorkflowClient($serviceClient, plugins: [$plugin]); + new WorkflowClient($serviceClient, pluginRegistry: new PluginRegistry([$plugin])); // Metadata should have been set (by plugin and then by WorkflowClient for namespace) self::assertNotNull($metadataSet); @@ -195,7 +196,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin1, $plugin2])); self::assertSame(['first', 'second'], $order); } @@ -226,7 +227,7 @@ public function configureClient(ClientPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertSame(['connection', 'client'], $order); } @@ -236,7 +237,7 @@ public function testDefaultTraitIsNoOp(): void $plugin = new class('test.noop') extends AbstractPlugin {}; // Should not throw — all trait methods are no-ops - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertNotNull($client); } @@ -259,7 +260,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertTrue($called); } @@ -285,7 +286,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void } }; - new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertTrue($called); } diff --git a/tests/Unit/Plugin/PluginPropagationTestCase.php b/tests/Unit/Plugin/PluginPropagationTestCase.php index cd27bf64b..56581d862 100644 --- a/tests/Unit/Plugin/PluginPropagationTestCase.php +++ b/tests/Unit/Plugin/PluginPropagationTestCase.php @@ -13,6 +13,8 @@ use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; use Temporal\Plugin\ClientPluginTrait; +use Temporal\Plugin\PluginInterface; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginContext; use Temporal\Plugin\ScheduleClientPluginInterface; use Temporal\Plugin\ScheduleClientPluginTrait; @@ -65,7 +67,7 @@ public function initializeWorker(WorkerInterface $worker): void } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertSame(['configureClient'], $order); @@ -119,12 +121,12 @@ public function configureWorker(WorkerPluginContext $context): void } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$clientPlugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$clientPlugin])); $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$factoryPlugin], + pluginRegistry: new PluginRegistry([$factoryPlugin]), client: $client, ); $factory->newWorker(); @@ -145,7 +147,7 @@ public function getName(): string } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$clientPlugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$clientPlugin])); $this->expectException(\RuntimeException::class); $this->expectExceptionMessage('Duplicate plugin "shared-name"'); @@ -153,7 +155,7 @@ public function getName(): string new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$factoryPlugin], + pluginRegistry: new PluginRegistry([$factoryPlugin]), client: $client, ); } @@ -173,7 +175,7 @@ public function getName(): string } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); // Client-only plugin should NOT appear in getWorkerPlugins self::assertCount(0, $client->getWorkerPlugins()); @@ -186,7 +188,7 @@ public function getName(): string ); $factory->newWorker(); - self::assertCount(0, $factory->getWorkerPlugins()); + self::assertCount(0, $factory->getPluginRegistry()->getPlugins(PluginInterface::class)); } public function testScheduleClientPluginPropagation(): void @@ -210,7 +212,7 @@ public function configureScheduleClient(ScheduleClientPluginContext $context): v } }; - $client = new WorkflowClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); $schedulePlugins = $client->getScheduleClientPlugins(); self::assertCount(1, $schedulePlugins); diff --git a/tests/Unit/Plugin/ScheduleClientPluginTestCase.php b/tests/Unit/Plugin/ScheduleClientPluginTestCase.php index 2e6ce1f2d..8c220b9f0 100644 --- a/tests/Unit/Plugin/ScheduleClientPluginTestCase.php +++ b/tests/Unit/Plugin/ScheduleClientPluginTestCase.php @@ -10,6 +10,7 @@ use Temporal\Client\GRPC\ServiceClientInterface; use Temporal\Client\ScheduleClient; use Temporal\DataConverter\DataConverterInterface; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginContext; use Temporal\Plugin\ScheduleClientPluginInterface; use Temporal\Plugin\ScheduleClientPluginTrait; @@ -41,7 +42,7 @@ public function configureScheduleClient(ScheduleClientPluginContext $context): v } }; - new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + new ScheduleClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertTrue($called); } @@ -64,7 +65,7 @@ public function configureScheduleClient(ScheduleClientPluginContext $context): v } }; - $client = new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new ScheduleClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertNotNull($client); } @@ -88,7 +89,7 @@ public function configureScheduleClient(ScheduleClientPluginContext $context): v } }; - $client = new ScheduleClient($this->mockServiceClient(), plugins: [$plugin]); + $client = new ScheduleClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); self::assertNotNull($client); } @@ -128,7 +129,7 @@ public function configureScheduleClient(ScheduleClientPluginContext $context): v } }; - new ScheduleClient($this->mockServiceClient(), plugins: [$plugin1, $plugin2]); + new ScheduleClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin1, $plugin2])); self::assertSame(['first', 'second'], $order); } @@ -171,7 +172,7 @@ public function configureScheduleClient(ScheduleClientPluginContext $context): v $this->mockServiceClient(), options: $initialOptions, converter: $initialConverter, - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); self::assertSame($initialOptions, $receivedOptions); diff --git a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php index 6f9e56651..7489dc95d 100644 --- a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php +++ b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php @@ -8,6 +8,8 @@ use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; use Temporal\Plugin\AbstractPlugin; +use Temporal\Plugin\PluginInterface; +use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerFactoryPluginContext; use Temporal\Plugin\WorkerPluginContext; use Temporal\Plugin\WorkerPluginInterface; @@ -49,7 +51,7 @@ public function configureWorkerFactory(WorkerFactoryPluginContext $context): voi new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); self::assertTrue($called); @@ -78,7 +80,7 @@ public function configureWorkerFactory(WorkerFactoryPluginContext $context): voi $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); self::assertSame($customConverter, $factory->getDataConverter()); @@ -112,7 +114,7 @@ public function configureWorker(WorkerPluginContext $context): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->newWorker('my-queue'); @@ -143,7 +145,7 @@ public function configureWorker(WorkerPluginContext $context): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $worker = $factory->newWorker('test-queue'); @@ -173,7 +175,7 @@ public function initializeWorker(WorkerInterface $worker): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $worker = $factory->newWorker('test-queue'); @@ -203,7 +205,7 @@ public function initializeWorker(WorkerInterface $worker): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->newWorker('my-task-queue'); @@ -243,7 +245,7 @@ public function initializeWorker(WorkerInterface $worker): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); self::assertSame(['configureWorkerFactory'], $order); @@ -296,7 +298,7 @@ public function configureWorker(WorkerPluginContext $context): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin1, $plugin2], + pluginRegistry: new PluginRegistry([$plugin1, $plugin2]), ); $factory->newWorker(); @@ -326,7 +328,7 @@ public function configureWorker(WorkerPluginContext $context): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->newWorker('queue-a'); $factory->newWorker('queue-b'); @@ -342,10 +344,10 @@ public function testGetWorkerPluginsReturnsRegistered(): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin1, $plugin2], + pluginRegistry: new PluginRegistry([$plugin1, $plugin2]), ); - $plugins = $factory->getWorkerPlugins(); + $plugins = $factory->getPluginRegistry()->getPlugins(PluginInterface::class); self::assertCount(2, $plugins); self::assertSame($plugin1, $plugins[0]); self::assertSame($plugin2, $plugins[1]); @@ -362,7 +364,7 @@ public function testDuplicatePluginThrowsException(): void new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin1, $plugin2], + pluginRegistry: new PluginRegistry([$plugin1, $plugin2]), ); } @@ -389,7 +391,7 @@ public function run(WorkerFactoryInterface $factory, callable $next): int $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->run($this->mockHost()); @@ -421,7 +423,7 @@ public function run(WorkerFactoryInterface $factory, callable $next): int $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->run($this->mockHost()); @@ -478,7 +480,7 @@ public function run(WorkerFactoryInterface $factory, callable $next): int $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin1, $plugin2], + pluginRegistry: new PluginRegistry([$plugin1, $plugin2]), ); $factory->run($this->mockHost()); @@ -519,7 +521,7 @@ public function run(WorkerFactoryInterface $factory, callable $next): int $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->run($this->mockHost()); @@ -566,7 +568,7 @@ public function run(WorkerFactoryInterface $factory, callable $next): int $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$outerPlugin, $innerPlugin], + pluginRegistry: new PluginRegistry([$outerPlugin, $innerPlugin]), ); $result = $factory->run($this->mockHost()); @@ -618,7 +620,7 @@ public function run(WorkerFactoryInterface $factory, callable $next): int $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $factory->newWorker(); $factory->run($this->mockHost()); @@ -651,7 +653,7 @@ public function testDefaultTraitRunPassesThrough(): void $factory = new WorkerFactory( DataConverter::createDefault(), $this->mockRpc(), - plugins: [$plugin], + pluginRegistry: new PluginRegistry([$plugin]), ); $result = $factory->run($this->mockHost()); From 56f5b99ec75538919e8faebe869f7dec829c7613 Mon Sep 17 00:00:00 2001 From: github-actions Date: Fri, 6 Mar 2026 08:19:52 +0000 Subject: [PATCH 08/23] style(php-cs-fixer): fix coding standards --- src/Client/ScheduleClient.php | 3 --- src/Client/WorkflowClient.php | 3 --- src/Plugin/TemporalPluginInterface.php | 4 +--- src/Worker/WorkerFactoryInterface.php | 1 - 4 files changed, 1 insertion(+), 10 deletions(-) diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index 4c06eb13e..9df8f95ea 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -52,9 +52,6 @@ final class ScheduleClient implements ScheduleClientInterface private ProtoToArrayConverter $protoConverter; private PluginRegistry $pluginRegistry; - /** - * @param list $plugins - */ public function __construct( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index 73f7a179f..066a51e0f 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -76,9 +76,6 @@ class WorkflowClient implements WorkflowClientInterface /** @var Pipeline */ private Pipeline $interceptorPipeline; - /** - * @param list $plugins - */ public function __construct( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, diff --git a/src/Plugin/TemporalPluginInterface.php b/src/Plugin/TemporalPluginInterface.php index f58b24821..93ff30f2a 100644 --- a/src/Plugin/TemporalPluginInterface.php +++ b/src/Plugin/TemporalPluginInterface.php @@ -18,6 +18,4 @@ * {@see ConnectionPluginInterface}, {@see ClientPluginInterface}, * {@see ScheduleClientPluginInterface}, or {@see WorkerPluginInterface} as needed. */ -interface TemporalPluginInterface extends ConnectionPluginInterface, ClientPluginInterface, ScheduleClientPluginInterface, WorkerPluginInterface -{ -} +interface TemporalPluginInterface extends ConnectionPluginInterface, ClientPluginInterface, ScheduleClientPluginInterface, WorkerPluginInterface {} diff --git a/src/Worker/WorkerFactoryInterface.php b/src/Worker/WorkerFactoryInterface.php index 3b1ef2d87..3777bd7cd 100644 --- a/src/Worker/WorkerFactoryInterface.php +++ b/src/Worker/WorkerFactoryInterface.php @@ -15,7 +15,6 @@ use Temporal\Exception\ExceptionInterceptorInterface; use Temporal\Interceptor\PipelineProvider; use Temporal\Plugin\PluginRegistry; -use Temporal\Plugin\WorkerPluginInterface; /** * The interface is responsible for providing an interface for registering all dependencies and creating a global From 2110e90aa75fa1530907d73acfca421d7fc160ab Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 18 Mar 2026 18:52:27 +0400 Subject: [PATCH 09/23] fix: psalm --- psalm-baseline.xml | 8 +--- src/Client/ScheduleClient.php | 5 +- src/Client/WorkflowClient.php | 8 ++-- src/Internal/Interceptor/Pipeline.php | 2 +- src/Plugin/CompositePipelineProvider.php | 60 ++++++++++-------------- src/WorkerFactory.php | 5 +- 6 files changed, 36 insertions(+), 52 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 10e3ad5fd..61dd32203 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -160,15 +160,9 @@ - - - - - - @@ -1475,6 +1469,8 @@ $converter ?? DataConverter::createDefault(), $rpc ?? Goridge::create(), $credentials, + $pluginRegistry ?? new PluginRegistry(), + $client, )]]> diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index 9df8f95ea..7f28cfd9b 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -77,8 +77,9 @@ public function __construct( $plugin->configureScheduleClient($pluginContext); } $this->clientOptions = $pluginContext->getClientOptions(); - if ($pluginContext->getDataConverter() !== null) { - $this->converter = $pluginContext->getDataConverter(); + $pluginConverter = $pluginContext->getDataConverter(); + if ($pluginConverter !== null) { + $this->converter = $pluginConverter; } $this->marshaller = new Marshaller( diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index 066a51e0f..6524045e0 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -103,8 +103,9 @@ public function __construct( } $this->clientOptions = $pluginContext->getClientOptions(); - if ($pluginContext->getDataConverter() !== null) { - $this->converter = $pluginContext->getDataConverter(); + $pluginConverter = $pluginContext->getDataConverter(); + if ($pluginConverter !== null) { + $this->converter = $pluginConverter; } // Build interceptor pipeline: merge plugin-contributed interceptors with user-provided ones @@ -125,9 +126,6 @@ public function __construct( ); } - /** - * @return static - */ public static function create( ServiceClientInterface $serviceClient, ?ClientOptions $options = null, diff --git a/src/Internal/Interceptor/Pipeline.php b/src/Internal/Interceptor/Pipeline.php index 484b777b6..1d7f9cc03 100644 --- a/src/Internal/Interceptor/Pipeline.php +++ b/src/Internal/Interceptor/Pipeline.php @@ -53,7 +53,7 @@ private function __construct( /** * Make sure that interceptors implement the same interface. * - * @template T of Interceptor + * @template T of object * * @param iterable $interceptors * diff --git a/src/Plugin/CompositePipelineProvider.php b/src/Plugin/CompositePipelineProvider.php index 599c3d410..1e3b7021e 100644 --- a/src/Plugin/CompositePipelineProvider.php +++ b/src/Plugin/CompositePipelineProvider.php @@ -28,54 +28,44 @@ final class CompositePipelineProvider implements PipelineProvider { private readonly PipelineProvider $delegate; + private array $cache = []; + /** * @param list $pluginInterceptors Interceptors contributed by plugins. * @param PipelineProvider $baseProvider The original user-provided pipeline provider. */ public function __construct( - array $pluginInterceptors, - PipelineProvider $baseProvider, + private readonly array $pluginInterceptors, + private readonly PipelineProvider $baseProvider, ) { $this->delegate = match (true) { $pluginInterceptors === [] => $baseProvider, $baseProvider instanceof SimplePipelineProvider => $baseProvider->withPrependedInterceptors($pluginInterceptors), - default => new class($pluginInterceptors, $baseProvider) implements PipelineProvider { - /** @var array */ - private array $cache = []; - - /** - * @param list $pluginInterceptors - */ - public function __construct( - private readonly array $pluginInterceptors, - private readonly PipelineProvider $baseProvider, - ) {} - - public function getPipeline(string $interceptorClass): Pipeline - { - if (isset($this->cache[$interceptorClass])) { - return $this->cache[$interceptorClass]; - } - - $filtered = \array_filter( - $this->pluginInterceptors, - static fn(Interceptor $i): bool => $i instanceof $interceptorClass, - ); - - if ($filtered === []) { - return $this->cache[$interceptorClass] = $this->baseProvider->getPipeline($interceptorClass); - } - - // Use only plugin interceptors - the base pipeline is lost in this edge case. - // Users should either use plugins OR a custom PipelineProvider, not both. - return $this->cache[$interceptorClass] = Pipeline::prepare($filtered); - } - }, + default => $this, }; } public function getPipeline(string $interceptorClass): Pipeline { - return $this->delegate->getPipeline($interceptorClass); + if ($this->delegate !== $this) { + return $this->delegate->getPipeline($interceptorClass); + } + + if (isset($this->cache[$interceptorClass])) { + return $this->cache[$interceptorClass]; + } + + $filtered = \array_filter( + $this->pluginInterceptors, + static fn(Interceptor $i): bool => $i instanceof $interceptorClass, + ); + + if ($filtered === []) { + return $this->cache[$interceptorClass] = $this->baseProvider->getPipeline($interceptorClass); + } + + // Use only plugin interceptors - the base pipeline is lost in this edge case. + // Users should either use plugins OR a custom PipelineProvider, not both. + return $this->cache[$interceptorClass] = Pipeline::prepare($filtered); } } diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index e2234c413..eb7fe3c9f 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -250,9 +250,8 @@ public function run(?HostConnectionInterface $host = null): int $host ??= RoadRunner::create(); $this->codec = $this->createCodec(); - $pipeline = Pipeline::prepare( - $this->pluginRegistry->getPlugins(WorkerPluginInterface::class), - ); + $plugins = $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + $pipeline = Pipeline::prepare($plugins); return $pipeline->with(function () use ($host): int { while ($msg = $host->waitBatch()) { From 5133c9b9ffc7cd8a213df805b16458d909ca09a0 Mon Sep 17 00:00:00 2001 From: github-actions Date: Wed, 18 Mar 2026 15:34:23 +0000 Subject: [PATCH 10/23] style(php-cs-fixer): fix coding standards --- src/Plugin/CompositePipelineProvider.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Plugin/CompositePipelineProvider.php b/src/Plugin/CompositePipelineProvider.php index 1e3b7021e..9da301e22 100644 --- a/src/Plugin/CompositePipelineProvider.php +++ b/src/Plugin/CompositePipelineProvider.php @@ -27,7 +27,6 @@ final class CompositePipelineProvider implements PipelineProvider { private readonly PipelineProvider $delegate; - private array $cache = []; /** From 8f6eda996e459c59100759ba4027b16dc79d85ce Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Thu, 19 Mar 2026 12:26:06 +0400 Subject: [PATCH 11/23] fix: trigger ci --- src/Plugin/ClientPluginContext.php | 1 - src/Plugin/ScheduleClientPluginContext.php | 1 - src/Plugin/WorkerFactoryPluginContext.php | 1 - src/Plugin/WorkerPluginContext.php | 1 - 4 files changed, 4 deletions(-) diff --git a/src/Plugin/ClientPluginContext.php b/src/Plugin/ClientPluginContext.php index fad098ecd..fa35e6fb5 100644 --- a/src/Plugin/ClientPluginContext.php +++ b/src/Plugin/ClientPluginContext.php @@ -19,7 +19,6 @@ * Builder-style configuration context for workflow client plugins. * * Plugins modify this builder in {@see ClientPluginInterface::configureClient()}. - * Uses a fluent API similar to Java SDK's Options.Builder pattern. */ final class ClientPluginContext { diff --git a/src/Plugin/ScheduleClientPluginContext.php b/src/Plugin/ScheduleClientPluginContext.php index c38ce683e..07ea102bf 100644 --- a/src/Plugin/ScheduleClientPluginContext.php +++ b/src/Plugin/ScheduleClientPluginContext.php @@ -18,7 +18,6 @@ * Builder-style configuration context for schedule client plugins. * * Plugins modify this builder in {@see ScheduleClientPluginInterface::configureScheduleClient()}. - * Uses a fluent API similar to Java SDK's Options.Builder pattern. */ final class ScheduleClientPluginContext { diff --git a/src/Plugin/WorkerFactoryPluginContext.php b/src/Plugin/WorkerFactoryPluginContext.php index 066d23f56..6ec51cac7 100644 --- a/src/Plugin/WorkerFactoryPluginContext.php +++ b/src/Plugin/WorkerFactoryPluginContext.php @@ -17,7 +17,6 @@ * Builder-style configuration context for worker factory plugins. * * Plugins modify this builder in {@see WorkerPluginInterface::configureWorkerFactory()}. - * Uses a fluent API similar to Java SDK's Options.Builder pattern. */ final class WorkerFactoryPluginContext { diff --git a/src/Plugin/WorkerPluginContext.php b/src/Plugin/WorkerPluginContext.php index 9c36311d2..c4f6dbbc1 100644 --- a/src/Plugin/WorkerPluginContext.php +++ b/src/Plugin/WorkerPluginContext.php @@ -19,7 +19,6 @@ * Builder-style configuration context for worker plugins. * * Plugins modify this builder in {@see WorkerPluginInterface::configureWorker()}. - * Uses a fluent API similar to Java SDK's Options.Builder pattern. */ final class WorkerPluginContext { From 478aebc1e46ad309424ae495ffa65db206ad816c Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Sat, 21 Mar 2026 17:47:48 +0400 Subject: [PATCH 12/23] feat: increase timers --- tests/Acceptance/Extra/Versioning/DeploymentTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Acceptance/Extra/Versioning/DeploymentTest.php b/tests/Acceptance/Extra/Versioning/DeploymentTest.php index 1db1a191e..58b29d281 100644 --- a/tests/Acceptance/Extra/Versioning/DeploymentTest.php +++ b/tests/Acceptance/Extra/Versioning/DeploymentTest.php @@ -176,7 +176,7 @@ private static function executeWorkflow( $client->start($stub); # Wait for the Workflow to complete - $stub->getResult(timeout: 5); + $stub->getResult(timeout: 10); # Check the Workflow History $behavior = null; From 89445d420914204b1f2fe70a9d5eccb748482bc4 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 09:31:25 +0400 Subject: [PATCH 13/23] feat: introduce callable chaining for plugins to enable sequential execution --- src/Client/ScheduleClient.php | 17 +- src/Client/WorkflowClient.php | 15 +- src/Plugin/ClientPluginInterface.php | 4 +- src/Plugin/ClientPluginTrait.php | 4 +- src/Plugin/ConnectionPluginInterface.php | 12 +- src/Plugin/ConnectionPluginTrait.php | 4 +- src/Plugin/ScheduleClientPluginInterface.php | 4 +- src/Plugin/ScheduleClientPluginTrait.php | 4 +- src/Plugin/WorkerPluginInterface.php | 12 +- src/Plugin/WorkerPluginTrait.php | 12 +- src/WorkerFactory.php | 21 +-- tests/Unit/Plugin/AbstractPluginTestCase.php | 10 +- tests/Unit/Plugin/ClientPluginTestCase.php | 18 ++- .../Unit/Plugin/ConnectionPluginTestCase.php | 56 +++++-- tests/Unit/Plugin/PluginChainTestCase.php | 152 ++++++++++++++++++ .../Unit/Plugin/PluginPropagationTestCase.php | 21 ++- tests/Unit/Plugin/PluginRegistryTestCase.php | 2 +- .../Plugin/ScheduleClientPluginTestCase.php | 18 ++- .../Plugin/WorkerFactoryPluginTestCase.php | 45 ++++-- 19 files changed, 335 insertions(+), 96 deletions(-) create mode 100644 tests/Unit/Plugin/PluginChainTestCase.php diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index 7f28cfd9b..1243f4415 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -32,6 +32,7 @@ use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\DataConverterInterface; use Temporal\Internal\Mapper\ScheduleMapper; +use Temporal\Internal\Interceptor\Pipeline; use Temporal\Plugin\ConnectionPluginContext; use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\PluginRegistry; @@ -64,18 +65,22 @@ public function __construct( // Apply connection plugins (before client-level configuration) $connectionContext = new ConnectionPluginContext($serviceClient); - foreach ($this->pluginRegistry->getPlugins(ConnectionPluginInterface::class) as $plugin) { - $plugin->configureServiceClient($connectionContext); - } + $connectionPlugins = $this->pluginRegistry->getPlugins(ConnectionPluginInterface::class); + /** @see ConnectionPluginInterface::configureServiceClient() */ + Pipeline::prepare($connectionPlugins) + ->with(static fn() => null, 'configureServiceClient')($connectionContext); + $serviceClient = $connectionContext->getServiceClient(); $pluginContext = new ScheduleClientPluginContext( clientOptions: $this->clientOptions, dataConverter: $this->converter, ); - foreach ($this->pluginRegistry->getPlugins(ScheduleClientPluginInterface::class) as $plugin) { - $plugin->configureScheduleClient($pluginContext); - } + $schedulePlugins = $this->pluginRegistry->getPlugins(ScheduleClientPluginInterface::class); + /** @see ScheduleClientPluginInterface::configureScheduleClient() */ + Pipeline::prepare($schedulePlugins) + ->with(static fn() => null, 'configureScheduleClient')($pluginContext); + $this->clientOptions = $pluginContext->getClientOptions(); $pluginConverter = $pluginContext->getDataConverter(); if ($pluginConverter !== null) { diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index 6524045e0..a4957c91e 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -89,18 +89,21 @@ public function __construct( // Apply connection plugins (before client-level configuration) $connectionContext = new ConnectionPluginContext($serviceClient); - foreach ($this->pluginRegistry->getPlugins(ConnectionPluginInterface::class) as $plugin) { - $plugin->configureServiceClient($connectionContext); - } + $connectionPlugins = $this->pluginRegistry->getPlugins(ConnectionPluginInterface::class); + /** @see ConnectionPluginInterface::configureServiceClient() */ + Pipeline::prepare($connectionPlugins) + ->with(static fn() => null, 'configureServiceClient')($connectionContext); + $serviceClient = $connectionContext->getServiceClient(); $pluginContext = new ClientPluginContext( clientOptions: $this->clientOptions, dataConverter: $this->converter, ); - foreach ($this->pluginRegistry->getPlugins(ClientPluginInterface::class) as $plugin) { - $plugin->configureClient($pluginContext); - } + $clientPlugins = $this->pluginRegistry->getPlugins(ClientPluginInterface::class); + /** @see ClientPluginInterface::configureClient() */ + Pipeline::prepare($clientPlugins) + ->with(static fn() => null, 'configureClient')($pluginContext); $this->clientOptions = $pluginContext->getClientOptions(); $pluginConverter = $pluginContext->getDataConverter(); diff --git a/src/Plugin/ClientPluginInterface.php b/src/Plugin/ClientPluginInterface.php index c7eb36878..94f82263c 100644 --- a/src/Plugin/ClientPluginInterface.php +++ b/src/Plugin/ClientPluginInterface.php @@ -25,6 +25,8 @@ interface ClientPluginInterface extends PluginInterface * Modify client configuration before the client is created. * * Called in registration order (first plugin registered = first called). + * + * @param callable(ClientPluginContext): void $next Calls the next plugin or the final hook. */ - public function configureClient(ClientPluginContext $context): void; + public function configureClient(ClientPluginContext $context, callable $next): void; } diff --git a/src/Plugin/ClientPluginTrait.php b/src/Plugin/ClientPluginTrait.php index d37638531..05529d998 100644 --- a/src/Plugin/ClientPluginTrait.php +++ b/src/Plugin/ClientPluginTrait.php @@ -18,8 +18,8 @@ */ trait ClientPluginTrait { - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { - // no-op + $next($context); } } diff --git a/src/Plugin/ConnectionPluginInterface.php b/src/Plugin/ConnectionPluginInterface.php index 4caa6f7fe..c6c113229 100644 --- a/src/Plugin/ConnectionPluginInterface.php +++ b/src/Plugin/ConnectionPluginInterface.php @@ -18,19 +18,15 @@ * them to set API keys, gRPC metadata, TLS context, and other * connection-level options. */ -interface ConnectionPluginInterface +interface ConnectionPluginInterface extends PluginInterface { - /** - * Unique name identifying this plugin (e.g., "my-org.cloud-auth"). - * Used for deduplication and diagnostics. - */ - public function getName(): string; - /** * Modify the service client before it is used by the client. * * Use this hook to configure connection-level settings such as * API keys, gRPC metadata, auth tokens, or context options. + * + * @param callable(ConnectionPluginContext): void $next Calls the next plugin or the final hook. */ - public function configureServiceClient(ConnectionPluginContext $context): void; + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void; } diff --git a/src/Plugin/ConnectionPluginTrait.php b/src/Plugin/ConnectionPluginTrait.php index 95759b168..bb8c62ddc 100644 --- a/src/Plugin/ConnectionPluginTrait.php +++ b/src/Plugin/ConnectionPluginTrait.php @@ -18,8 +18,8 @@ */ trait ConnectionPluginTrait { - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { - // no-op + $next($context); } } diff --git a/src/Plugin/ScheduleClientPluginInterface.php b/src/Plugin/ScheduleClientPluginInterface.php index c070d5665..e47b081a7 100644 --- a/src/Plugin/ScheduleClientPluginInterface.php +++ b/src/Plugin/ScheduleClientPluginInterface.php @@ -25,6 +25,8 @@ interface ScheduleClientPluginInterface extends PluginInterface * Modify schedule client configuration before the client is created. * * Called in registration order (first plugin registered = first called). + * + * @param callable(ScheduleClientPluginContext): void $next Calls the next plugin or the final hook. */ - public function configureScheduleClient(ScheduleClientPluginContext $context): void; + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void; } diff --git a/src/Plugin/ScheduleClientPluginTrait.php b/src/Plugin/ScheduleClientPluginTrait.php index 0268f4f76..2a08074ff 100644 --- a/src/Plugin/ScheduleClientPluginTrait.php +++ b/src/Plugin/ScheduleClientPluginTrait.php @@ -18,8 +18,8 @@ */ trait ScheduleClientPluginTrait { - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { - // no-op + $next($context); } } diff --git a/src/Plugin/WorkerPluginInterface.php b/src/Plugin/WorkerPluginInterface.php index e496d6979..fded696aa 100644 --- a/src/Plugin/WorkerPluginInterface.php +++ b/src/Plugin/WorkerPluginInterface.php @@ -25,15 +25,19 @@ interface WorkerPluginInterface extends PluginInterface { /** * Modify worker factory configuration before it is fully initialized. + * + * @param callable(WorkerFactoryPluginContext): void $next Calls the next plugin or the final hook. */ - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void; + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void; /** * Modify worker configuration before the worker is created. * * Task queue name is available via {@see WorkerPluginContext::getTaskQueue()}. + * + * @param callable(WorkerPluginContext): void $next Calls the next plugin or the final hook. */ - public function configureWorker(WorkerPluginContext $context): void; + public function configureWorker(WorkerPluginContext $context, callable $next): void; /** * Called after a worker is created, allowing plugins to register workflows, @@ -44,8 +48,10 @@ public function configureWorker(WorkerPluginContext $context): void; * because it is called before the worker starts polling. * * Task queue name is available via {@see WorkerInterface::getID()}. + * + * @param callable(WorkerInterface): void $next Calls the next plugin or the final hook. */ - public function initializeWorker(WorkerInterface $worker): void; + public function initializeWorker(WorkerInterface $worker, callable $next): void; /** * Wraps the worker factory run lifecycle using chain-of-responsibility. diff --git a/src/Plugin/WorkerPluginTrait.php b/src/Plugin/WorkerPluginTrait.php index a2750f4f6..d47965bce 100644 --- a/src/Plugin/WorkerPluginTrait.php +++ b/src/Plugin/WorkerPluginTrait.php @@ -21,19 +21,19 @@ */ trait WorkerPluginTrait { - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void { - // No-op by default + $next($context); } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { - // No-op by default + $next($context); } - public function initializeWorker(WorkerInterface $worker): void + public function initializeWorker(WorkerInterface $worker, callable $next): void { - // No-op by default + $next($worker); } public function run(WorkerFactoryInterface $factory, callable $next): int diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index fcc56dba8..674c14176 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -29,6 +29,7 @@ use Temporal\Internal\Events\EventEmitterTrait; use Temporal\Internal\Interceptor\Pipeline; use Temporal\Plugin\CompositePipelineProvider; +use Temporal\Plugin\PluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerFactoryPluginContext; use Temporal\Plugin\WorkerPluginContext; @@ -130,9 +131,10 @@ public function __construct( $factoryContext = new WorkerFactoryPluginContext( dataConverter: $dataConverter, ); - foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { - $plugin->configureWorkerFactory($factoryContext); - } + $workerPlugins = $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + /** @see WorkerPluginInterface::configureWorkerFactory() */ + Pipeline::prepare($workerPlugins) + ->with(static fn() => null, 'configureWorkerFactory')($factoryContext); $this->converter = $factoryContext->getDataConverter() ?? $dataConverter; $this->codec = $this->createCodec(); @@ -170,9 +172,10 @@ public function newWorker( workerOptions: $options, exceptionInterceptor: $exceptionInterceptor, ); - foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { - $plugin->configureWorker($workerContext); - } + $workerPlugins = $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + /** @see WorkerPluginInterface::configureWorker() */ + Pipeline::prepare($workerPlugins) + ->with(static fn() => null, 'configureWorker')($workerContext); $options = $workerContext->getWorkerOptions(); @@ -199,9 +202,9 @@ public function newWorker( ); // Call initializeWorker hooks (forward order) - foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { - $plugin->initializeWorker($worker); - } + /** @see WorkerPluginInterface::initializeWorker() */ + Pipeline::prepare($workerPlugins) + ->with(static fn() => null, 'initializeWorker')($worker); $this->queues->add($worker); diff --git a/tests/Unit/Plugin/AbstractPluginTestCase.php b/tests/Unit/Plugin/AbstractPluginTestCase.php index 84f2dbac3..4a64e674d 100644 --- a/tests/Unit/Plugin/AbstractPluginTestCase.php +++ b/tests/Unit/Plugin/AbstractPluginTestCase.php @@ -33,7 +33,7 @@ public function testConfigureClientPassthrough(): void $context = new ClientPluginContext(new ClientOptions()); $clone = clone $context; - $plugin->configureClient($context); + $plugin->configureClient($context, static fn() => null); self::assertSame($clone->getClientOptions(), $context->getClientOptions()); self::assertSame($clone->getDataConverter(), $context->getDataConverter()); @@ -45,7 +45,7 @@ public function testConfigureScheduleClientPassthrough(): void $context = new ScheduleClientPluginContext(new ClientOptions()); $clone = clone $context; - $plugin->configureScheduleClient($context); + $plugin->configureScheduleClient($context, static fn() => null); self::assertSame($clone->getClientOptions(), $context->getClientOptions()); self::assertSame($clone->getDataConverter(), $context->getDataConverter()); @@ -57,7 +57,7 @@ public function testConfigureWorkerFactoryPassthrough(): void $context = new WorkerFactoryPluginContext(); $clone = clone $context; - $plugin->configureWorkerFactory($context); + $plugin->configureWorkerFactory($context, static fn() => null); self::assertSame($clone->getDataConverter(), $context->getDataConverter()); } @@ -68,7 +68,7 @@ public function testConfigureWorkerPassthrough(): void $context = new WorkerPluginContext('test-queue', WorkerOptions::new()); $clone = clone $context; - $plugin->configureWorker($context); + $plugin->configureWorker($context, static fn() => null); self::assertSame($clone->getWorkerOptions(), $context->getWorkerOptions()); self::assertSame($clone->getExceptionInterceptor(), $context->getExceptionInterceptor()); @@ -80,7 +80,7 @@ public function testInitializeWorkerNoop(): void $worker = $this->createMock(WorkerInterface::class); // Should not throw - $plugin->initializeWorker($worker); + $plugin->initializeWorker($worker, static fn() => null); self::assertTrue(true); } } diff --git a/tests/Unit/Plugin/ClientPluginTestCase.php b/tests/Unit/Plugin/ClientPluginTestCase.php index 8af441763..0900406f7 100644 --- a/tests/Unit/Plugin/ClientPluginTestCase.php +++ b/tests/Unit/Plugin/ClientPluginTestCase.php @@ -41,9 +41,10 @@ public function getName(): string return 'test.spy'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; @@ -62,11 +63,12 @@ public function getName(): string return 'test.namespace'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $context->setClientOptions( (new ClientOptions())->withNamespace('plugin-namespace'), ); + $next($context); } }; @@ -90,9 +92,10 @@ public function getName(): string return 'test.converter'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $context->setDataConverter($this->converter); + $next($context); } }; @@ -117,9 +120,10 @@ public function getName(): string return 'test.interceptor'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $context->addInterceptor($this->interceptor); + $next($context); } }; @@ -142,9 +146,10 @@ public function getName(): string return 'test.first'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $this->order[] = 'first'; + $next($context); } }; @@ -158,9 +163,10 @@ public function getName(): string return 'test.second'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $this->order[] = 'second'; + $next($context); } }; diff --git a/tests/Unit/Plugin/ConnectionPluginTestCase.php b/tests/Unit/Plugin/ConnectionPluginTestCase.php index a7e19d090..6b39dd5f1 100644 --- a/tests/Unit/Plugin/ConnectionPluginTestCase.php +++ b/tests/Unit/Plugin/ConnectionPluginTestCase.php @@ -42,9 +42,10 @@ public function getName(): string return 'test.connection'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; @@ -67,9 +68,10 @@ public function getName(): string return 'test.connection'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; @@ -100,11 +102,12 @@ public function getName(): string return 'test.auth'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $context->setServiceClient( $context->getServiceClient()->withAuthKey('my-api-key'), ); + $next($context); } }; @@ -140,7 +143,7 @@ public function getName(): string return 'test.metadata'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $client = $context->getServiceClient(); $ctx = $client->getContext(); @@ -149,6 +152,7 @@ public function configureServiceClient(ConnectionPluginContext $context): void $ctx->withMetadata(['x-custom-header' => ['value']] + $ctx->getMetadata()), ), ); + $next($context); } }; @@ -173,9 +177,10 @@ public function getName(): string return 'test.first'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->order[] = 'first'; + $next($context); } }; @@ -190,9 +195,10 @@ public function getName(): string return 'test.second'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->order[] = 'second'; + $next($context); } }; @@ -216,14 +222,16 @@ public function getName(): string return 'test.order'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->order[] = 'connection'; + $next($context); } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $this->order[] = 'client'; + $next($context); } }; @@ -254,9 +262,10 @@ public function __construct(bool &$called) $this->ref = &$called; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->ref = true; + $next($context); } }; @@ -280,9 +289,10 @@ public function getName(): string return 'test.conn-only'; } - public function configureServiceClient(ConnectionPluginContext $context): void + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; @@ -291,6 +301,32 @@ public function configureServiceClient(ConnectionPluginContext $context): void self::assertTrue($called); } + public function testConnectionPluginCanInterceptByWrappingNext(): void + { + $order = []; + $plugin = new class($order) implements ConnectionPluginInterface { + use ConnectionPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test.interceptor'; + } + + public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + { + $this->order[] = 'before'; + $next($context); + $this->order[] = 'after'; + } + }; + + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); + + self::assertSame(['before', 'after'], $order); + } + private function mockServiceClient(): ServiceClientInterface { $context = $this->createMock(ContextInterface::class); diff --git a/tests/Unit/Plugin/PluginChainTestCase.php b/tests/Unit/Plugin/PluginChainTestCase.php new file mode 100644 index 000000000..41950463f --- /dev/null +++ b/tests/Unit/Plugin/PluginChainTestCase.php @@ -0,0 +1,152 @@ +order[] = 'before'; + $next($context); + $this->order[] = 'after'; + } + }; + + new WorkflowClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); + + self::assertSame(['before', 'after'], $order); + } + + public function testScheduleClientPluginInterception(): void + { + $order = []; + $plugin = new class($order) implements ScheduleClientPluginInterface { + use ScheduleClientPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test'; + } + + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void + { + $this->order[] = 'before'; + $next($context); + $this->order[] = 'after'; + } + }; + + new ScheduleClient($this->mockServiceClient(), pluginRegistry: new PluginRegistry([$plugin])); + + self::assertSame(['before', 'after'], $order); + } + + public function testWorkerFactoryPluginInterception(): void + { + $order = []; + $plugin = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test'; + } + + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void + { + $this->order[] = 'before'; + $next($context); + $this->order[] = 'after'; + } + }; + + WorkerFactory::create(pluginRegistry: new PluginRegistry([$plugin])); + + self::assertSame(['before', 'after'], $order); + } + + public function testWorkerPluginInterception(): void + { + $order = []; + $plugin = new class($order) implements WorkerPluginInterface { + use WorkerPluginTrait; + + public function __construct(private array &$order) {} + + public function getName(): string + { + return 'test'; + } + + public function configureWorker(WorkerPluginContext $context, callable $next): void + { + $this->order[] = 'before_config'; + $next($context); + $this->order[] = 'after_config'; + } + + public function initializeWorker(WorkerInterface $worker, callable $next): void + { + $this->order[] = 'before_init'; + $next($worker); + $this->order[] = 'after_init'; + } + }; + + $factory = WorkerFactory::create(pluginRegistry: new PluginRegistry([$plugin])); + $factory->newWorker('test-queue'); + + self::assertSame(['before_config', 'after_config', 'before_init', 'after_init'], $order); + } + + private function mockServiceClient(): ServiceClientInterface + { + $context = $this->createMock(ContextInterface::class); + $context->method('getMetadata')->willReturn([]); + $context->method('withMetadata')->willReturn($context); + + $client = $this->createMock(ServiceClientInterface::class); + $client->method('getContext')->willReturn($context); + $client->method('withContext')->willReturn($client); + + return $client; + } +} diff --git a/tests/Unit/Plugin/PluginPropagationTestCase.php b/tests/Unit/Plugin/PluginPropagationTestCase.php index 56581d862..31341d02d 100644 --- a/tests/Unit/Plugin/PluginPropagationTestCase.php +++ b/tests/Unit/Plugin/PluginPropagationTestCase.php @@ -46,24 +46,28 @@ public function __construct(private array &$order) parent::__construct('test.propagation'); } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $this->order[] = 'configureClient'; + $next($context); } - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void { $this->order[] = 'configureWorkerFactory'; + $next($context); } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'configureWorker'; + $next($context); } - public function initializeWorker(WorkerInterface $worker): void + public function initializeWorker(WorkerInterface $worker, callable $next): void { $this->order[] = 'initializeWorker'; + $next($worker); } }; @@ -99,9 +103,10 @@ public function __construct(private array &$order) parent::__construct('test.from-client'); } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'from-client'; + $next($context); } }; @@ -115,9 +120,10 @@ public function getName(): string return 'test.from-factory'; } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'from-factory'; + $next($context); } }; @@ -206,9 +212,10 @@ public function getName(): string return 'test.schedule-combo'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; diff --git a/tests/Unit/Plugin/PluginRegistryTestCase.php b/tests/Unit/Plugin/PluginRegistryTestCase.php index b8dde7e79..8e5df0571 100644 --- a/tests/Unit/Plugin/PluginRegistryTestCase.php +++ b/tests/Unit/Plugin/PluginRegistryTestCase.php @@ -78,7 +78,7 @@ public function getName(): string return 'client-only'; } - public function configureClient(ClientPluginContext $context): void {} + public function configureClient(ClientPluginContext $context, callable $next): void {} }; $workerPlugin = new class implements WorkerPluginInterface { diff --git a/tests/Unit/Plugin/ScheduleClientPluginTestCase.php b/tests/Unit/Plugin/ScheduleClientPluginTestCase.php index 8c220b9f0..f2fe1670d 100644 --- a/tests/Unit/Plugin/ScheduleClientPluginTestCase.php +++ b/tests/Unit/Plugin/ScheduleClientPluginTestCase.php @@ -36,9 +36,10 @@ public function getName(): string return 'test.spy'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; @@ -57,11 +58,12 @@ public function getName(): string return 'test.namespace'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $context->setClientOptions( (new ClientOptions())->withNamespace('schedule-namespace'), ); + $next($context); } }; @@ -83,9 +85,10 @@ public function getName(): string return 'test.converter'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $context->setDataConverter($this->converter); + $next($context); } }; @@ -107,9 +110,10 @@ public function getName(): string return 'test.first'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $this->order[] = 'first'; + $next($context); } }; @@ -123,9 +127,10 @@ public function getName(): string return 'test.second'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $this->order[] = 'second'; + $next($context); } }; @@ -161,10 +166,11 @@ public function getName(): string return 'test.inspector'; } - public function configureScheduleClient(ScheduleClientPluginContext $context): void + public function configureScheduleClient(ScheduleClientPluginContext $context, callable $next): void { $this->receivedOptions = $context->getClientOptions(); $this->receivedConverter = $context->getDataConverter(); + $next($context); } }; diff --git a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php index 7489dc95d..fe12b6ec9 100644 --- a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php +++ b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php @@ -42,9 +42,10 @@ public function getName(): string return 'test.spy'; } - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void { $this->called = true; + $next($context); } }; @@ -71,9 +72,10 @@ public function getName(): string return 'test.converter'; } - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void { $context->setDataConverter($this->converter); + $next($context); } }; @@ -104,10 +106,11 @@ public function getName(): string return 'test.spy'; } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->called = true; $this->receivedTaskQueue = $context->getTaskQueue(); + $next($context); } }; @@ -136,9 +139,10 @@ public function getName(): string return 'test.options'; } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $context->setWorkerOptions($this->opts); + $next($context); } }; @@ -166,9 +170,10 @@ public function getName(): string return 'test.init'; } - public function initializeWorker(WorkerInterface $worker): void + public function initializeWorker(WorkerInterface $worker, callable $next): void { $this->receivedWorker = $worker; + $next($worker); } }; @@ -196,9 +201,10 @@ public function getName(): string return 'test.tq'; } - public function initializeWorker(WorkerInterface $worker): void + public function initializeWorker(WorkerInterface $worker, callable $next): void { $this->receivedTaskQueue = $worker->getID(); + $next($worker); } }; @@ -226,19 +232,22 @@ public function getName(): string return 'test.order'; } - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void { $this->order[] = 'configureWorkerFactory'; + $next($context); } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'configureWorker'; + $next($context); } - public function initializeWorker(WorkerInterface $worker): void + public function initializeWorker(WorkerInterface $worker, callable $next): void { $this->order[] = 'initializeWorker'; + $next($worker); } }; @@ -273,9 +282,10 @@ public function getName(): string return 'test.first'; } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'first'; + $next($context); } }; @@ -289,9 +299,10 @@ public function getName(): string return 'test.second'; } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'second'; + $next($context); } }; @@ -319,9 +330,10 @@ public function getName(): string return 'test.per-worker'; } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->taskQueues[] = $context->getTaskQueue(); + $next($context); } }; @@ -591,19 +603,22 @@ public function getName(): string return 'test.lifecycle'; } - public function configureWorkerFactory(WorkerFactoryPluginContext $context): void + public function configureWorkerFactory(WorkerFactoryPluginContext $context, callable $next): void { $this->order[] = 'configureWorkerFactory'; + $next($context); } - public function configureWorker(WorkerPluginContext $context): void + public function configureWorker(WorkerPluginContext $context, callable $next): void { $this->order[] = 'configureWorker'; + $next($context); } - public function initializeWorker(WorkerInterface $worker): void + public function initializeWorker(WorkerInterface $worker, callable $next): void { $this->order[] = 'initializeWorker'; + $next($worker); } public function run(WorkerFactoryInterface $factory, callable $next): int From 184f55bf4dcc3c3eb53ad79b43633305f652628e Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 09:52:36 +0400 Subject: [PATCH 14/23] refactor: consolidate plugin type to `PluginInterface` in `PluginRegistry` --- src/Plugin/PluginRegistry.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/src/Plugin/PluginRegistry.php b/src/Plugin/PluginRegistry.php index ef5ebe127..a22d7605d 100644 --- a/src/Plugin/PluginRegistry.php +++ b/src/Plugin/PluginRegistry.php @@ -18,16 +18,15 @@ * {@see WorkerPluginInterface::getName()} value. When a duplicate is detected, * an exception is thrown. * - * @psalm-type TPlugin = (ConnectionPluginInterface|ClientPluginInterface|ScheduleClientPluginInterface|WorkerPluginInterface) * @internal */ final class PluginRegistry { - /** @var array */ + /** @var array */ private array $plugins = []; /** - * @param iterable $plugins + * @param iterable $plugins */ public function __construct(iterable $plugins = []) { @@ -36,7 +35,7 @@ public function __construct(iterable $plugins = []) } } - public function add(ConnectionPluginInterface|ClientPluginInterface|ScheduleClientPluginInterface|WorkerPluginInterface $plugin): void + public function add(PluginInterface $plugin): void { $name = $plugin->getName(); if (isset($this->plugins[$name])) { @@ -51,7 +50,7 @@ public function add(ConnectionPluginInterface|ClientPluginInterface|ScheduleClie /** * Merge another set of plugins. Throws on duplicate names. * - * @param iterable $plugins + * @param iterable $plugins */ public function merge(iterable $plugins): void { @@ -63,7 +62,7 @@ public function merge(iterable $plugins): void /** * Get all plugins implementing a given interface. * - * @template T of TPlugin + * @template T of PluginInterface * @param class-string $interface * @return list */ From f371b7815fb02140cd2478a5d5711759592c7ff6 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 09:52:42 +0400 Subject: [PATCH 15/23] fix: adjust plugin propagation order between client and factory --- src/WorkerFactory.php | 11 ++++++++--- tests/Unit/Plugin/PluginPropagationTestCase.php | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index 674c14176..7fea8e649 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -121,12 +121,17 @@ public function __construct( ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, ) { - $this->pluginRegistry = $pluginRegistry ?? new PluginRegistry(); - // Propagate worker plugins from the client + $this->pluginRegistry = new PluginRegistry(); + // Propagate worker plugins from the client first if ($client !== null) { $this->pluginRegistry->merge($client->getWorkerPlugins()); } + // Add factory plugins after client plugins + if ($pluginRegistry !== null) { + $this->pluginRegistry->merge($pluginRegistry->getPlugins(PluginInterface::class)); + } + // Apply worker factory plugins $factoryContext = new WorkerFactoryPluginContext( dataConverter: $dataConverter, @@ -152,7 +157,7 @@ public static function create( $converter ?? DataConverter::createDefault(), $rpc ?? Goridge::create(), $credentials, - $pluginRegistry ?? new PluginRegistry(), + $pluginRegistry, $client, ); } diff --git a/tests/Unit/Plugin/PluginPropagationTestCase.php b/tests/Unit/Plugin/PluginPropagationTestCase.php index 31341d02d..3b64a393c 100644 --- a/tests/Unit/Plugin/PluginPropagationTestCase.php +++ b/tests/Unit/Plugin/PluginPropagationTestCase.php @@ -137,7 +137,7 @@ public function configureWorker(WorkerPluginContext $context, callable $next): v ); $factory->newWorker(); - self::assertSame(['from-factory', 'from-client'], $order); + self::assertSame(['from-client', 'from-factory'], $order); } public function testDuplicateAcrossClientAndFactoryThrows(): void From b1e4a81572e12ff44f6572561a31c3139898a8d5 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 17:36:07 +0400 Subject: [PATCH 16/23] feat: add callable chaining support to `configureClient` in tests --- tests/Acceptance/Extra/Plugin/ClientPluginTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php index 53f460164..1cd9ab1e1 100644 --- a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php +++ b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php @@ -136,7 +136,7 @@ public function getName(): string return 'prefix-plugin'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $context->addInterceptor(new PrefixInterceptor($this->prefix)); } @@ -154,7 +154,7 @@ public function getName(): string return 'prefix-plugin-2'; } - public function configureClient(ClientPluginContext $context): void + public function configureClient(ClientPluginContext $context, callable $next): void { $context->addInterceptor(new PrefixInterceptor($this->prefix)); } From 321483bf2d3bc8578175c1bced067073fa6bd180 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 17:36:21 +0400 Subject: [PATCH 17/23] refactor: streamline worker plugin execution with `Pipeline` abstraction --- testing/src/WorkerFactory.php | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/testing/src/WorkerFactory.php b/testing/src/WorkerFactory.php index 339ec1a47..cb68951fc 100644 --- a/testing/src/WorkerFactory.php +++ b/testing/src/WorkerFactory.php @@ -13,6 +13,7 @@ use Temporal\Exception\ExceptionInterceptorInterface; use Temporal\Interceptor\PipelineProvider; use Temporal\Interceptor\SimplePipelineProvider; +use Temporal\Internal\Interceptor\Pipeline; use Temporal\Internal\ServiceContainer; use Temporal\Internal\Workflow\Logger; use Temporal\Plugin\CompositePipelineProvider; @@ -35,12 +36,12 @@ class WorkerFactory extends \Temporal\WorkerFactory public function __construct( DataConverterInterface $dataConverter, RPCConnectionInterface $rpc, - ActivityInvocationCacheInterface $activityCache, ?ServiceCredentials $credentials = null, ?PluginRegistry $pluginRegistry = null, ?WorkflowClient $client = null, + ?ActivityInvocationCacheInterface $activityCache = null, ) { - $this->activityCache = $activityCache; + $this->activityCache = $activityCache ?? RoadRunnerActivityInvocationCache::create($dataConverter); parent::__construct($dataConverter, $rpc, $credentials ?? ServiceCredentials::create(), $pluginRegistry, $client); } @@ -59,10 +60,10 @@ public static function create( return new static( $converter ?? DataConverter::createDefault(), $rpc ?? Goridge::create(), - $activityCache ?? RoadRunnerActivityInvocationCache::create($converter), $credentials, $pluginRegistry ?? new PluginRegistry(), $client, + $activityCache, ); } @@ -80,9 +81,10 @@ public function newWorker( workerOptions: $options, exceptionInterceptor: $exceptionInterceptor, ); - foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { - $plugin->configureWorker($workerContext); - } + $workerPlugins = $this->pluginRegistry->getPlugins(WorkerPluginInterface::class); + /** @see WorkerPluginInterface::configureWorker() */ + Pipeline::prepare($workerPlugins) + ->with(static fn() => null, 'configureWorker')($workerContext); $options = $workerContext->getWorkerOptions(); @@ -112,9 +114,9 @@ public function newWorker( ); // Call initializeWorker hooks (forward order) - foreach ($this->pluginRegistry->getPlugins(WorkerPluginInterface::class) as $plugin) { - $plugin->initializeWorker($worker); - } + /** @see WorkerPluginInterface::initializeWorker() */ + Pipeline::prepare($workerPlugins) + ->with(static fn() => null, 'initializeWorker')($worker); $this->queues->add($worker); From 0abd21d3e7c7a6dc39098f5c53040fb68ffc0b7e Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 17:37:24 +0400 Subject: [PATCH 18/23] feat: ensure `configureClient` invokes next callable in plugin chain --- tests/Acceptance/Extra/Plugin/ClientPluginTest.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php index 1cd9ab1e1..b23cea2f2 100644 --- a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php +++ b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php @@ -139,6 +139,7 @@ public function getName(): string public function configureClient(ClientPluginContext $context, callable $next): void { $context->addInterceptor(new PrefixInterceptor($this->prefix)); + $next($context); } } @@ -157,6 +158,7 @@ public function getName(): string public function configureClient(ClientPluginContext $context, callable $next): void { $context->addInterceptor(new PrefixInterceptor($this->prefix)); + $next($context); } } From 753024688c92609a646be3fe5bc180cafcdde3b9 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 18:51:43 +0400 Subject: [PATCH 19/23] refactor: replace `ConnectionPluginContext` with `ServiceClientInterface` in plugin execution --- src/Client/WorkflowClient.php | 13 ++--- src/Plugin/ConnectionPluginInterface.php | 6 ++- src/Plugin/ConnectionPluginTrait.php | 6 ++- .../Unit/Plugin/ConnectionPluginTestCase.php | 51 +++++++++---------- 4 files changed, 37 insertions(+), 39 deletions(-) diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index a4957c91e..637a60349 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -88,22 +88,19 @@ public function __construct( $this->converter = $converter ?? DataConverter::createDefault(); // Apply connection plugins (before client-level configuration) - $connectionContext = new ConnectionPluginContext($serviceClient); $connectionPlugins = $this->pluginRegistry->getPlugins(ConnectionPluginInterface::class); - /** @see ConnectionPluginInterface::configureServiceClient() */ - Pipeline::prepare($connectionPlugins) - ->with(static fn() => null, 'configureServiceClient')($connectionContext); - - $serviceClient = $connectionContext->getServiceClient(); + $serviceClient = Pipeline::prepare($connectionPlugins) + /** @see ConnectionPluginInterface::configureServiceClient() */ + ->with(static fn(ServiceClientInterface $serviceClient) => $serviceClient, 'configureServiceClient')($serviceClient); $pluginContext = new ClientPluginContext( clientOptions: $this->clientOptions, dataConverter: $this->converter, ); $clientPlugins = $this->pluginRegistry->getPlugins(ClientPluginInterface::class); - /** @see ClientPluginInterface::configureClient() */ Pipeline::prepare($clientPlugins) - ->with(static fn() => null, 'configureClient')($pluginContext); + /** @see ClientPluginInterface::configureClient() */ + ->with(static fn(ClientPluginContext $pluginContext) => $pluginContext, 'configureClient')($pluginContext); $this->clientOptions = $pluginContext->getClientOptions(); $pluginConverter = $pluginContext->getDataConverter(); diff --git a/src/Plugin/ConnectionPluginInterface.php b/src/Plugin/ConnectionPluginInterface.php index c6c113229..f6ee84c7c 100644 --- a/src/Plugin/ConnectionPluginInterface.php +++ b/src/Plugin/ConnectionPluginInterface.php @@ -11,6 +11,8 @@ namespace Temporal\Plugin; +use Temporal\Client\GRPC\ServiceClientInterface; + /** * Plugin interface for configuring the service client connection. * @@ -26,7 +28,7 @@ interface ConnectionPluginInterface extends PluginInterface * Use this hook to configure connection-level settings such as * API keys, gRPC metadata, auth tokens, or context options. * - * @param callable(ConnectionPluginContext): void $next Calls the next plugin or the final hook. + * @param callable(ServiceClientInterface): void $next Calls the next plugin or the final hook. */ - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void; + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface; } diff --git a/src/Plugin/ConnectionPluginTrait.php b/src/Plugin/ConnectionPluginTrait.php index bb8c62ddc..f95396e33 100644 --- a/src/Plugin/ConnectionPluginTrait.php +++ b/src/Plugin/ConnectionPluginTrait.php @@ -11,6 +11,8 @@ namespace Temporal\Plugin; +use Temporal\Client\GRPC\ServiceClientInterface; + /** * No-op defaults for {@see ConnectionPluginInterface}. * @@ -18,8 +20,8 @@ */ trait ConnectionPluginTrait { - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { - $next($context); + return $next($serviceClient); } } diff --git a/tests/Unit/Plugin/ConnectionPluginTestCase.php b/tests/Unit/Plugin/ConnectionPluginTestCase.php index 6b39dd5f1..c4b332363 100644 --- a/tests/Unit/Plugin/ConnectionPluginTestCase.php +++ b/tests/Unit/Plugin/ConnectionPluginTestCase.php @@ -13,7 +13,6 @@ use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; use Temporal\Plugin\ClientPluginTrait; -use Temporal\Plugin\ConnectionPluginContext; use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\ConnectionPluginTrait; use Temporal\Plugin\PluginRegistry; @@ -42,10 +41,10 @@ public function getName(): string return 'test.connection'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->called = true; - $next($context); + return $next($serviceClient); } }; @@ -68,10 +67,10 @@ public function getName(): string return 'test.connection'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->called = true; - $next($context); + return $next($serviceClient); } }; @@ -102,12 +101,11 @@ public function getName(): string return 'test.auth'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { - $context->setServiceClient( - $context->getServiceClient()->withAuthKey('my-api-key'), + return $next( + $serviceClient->withAuthKey('my-api-key'), ); - $next($context); } }; @@ -143,16 +141,14 @@ public function getName(): string return 'test.metadata'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { - $client = $context->getServiceClient(); - $ctx = $client->getContext(); - $context->setServiceClient( - $client->withContext( + $ctx = $serviceClient->getContext(); + return $next( + $serviceClient->withContext( $ctx->withMetadata(['x-custom-header' => ['value']] + $ctx->getMetadata()), ), ); - $next($context); } }; @@ -177,10 +173,10 @@ public function getName(): string return 'test.first'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->order[] = 'first'; - $next($context); + return $next($serviceClient); } }; @@ -195,10 +191,10 @@ public function getName(): string return 'test.second'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->order[] = 'second'; - $next($context); + return $next($serviceClient); } }; @@ -222,10 +218,10 @@ public function getName(): string return 'test.order'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->order[] = 'connection'; - $next($context); + return $next($serviceClient); } public function configureClient(ClientPluginContext $context, callable $next): void @@ -262,10 +258,10 @@ public function __construct(bool &$called) $this->ref = &$called; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->ref = true; - $next($context); + return $next($serviceClient); } }; @@ -289,10 +285,10 @@ public function getName(): string return 'test.conn-only'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->called = true; - $next($context); + return $next($serviceClient); } }; @@ -314,11 +310,12 @@ public function getName(): string return 'test.interceptor'; } - public function configureServiceClient(ConnectionPluginContext $context, callable $next): void + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface { $this->order[] = 'before'; - $next($context); + $client = $next($serviceClient); $this->order[] = 'after'; + return $client; } }; From f20d66b592a8c625d5737384bf887596f8bc8f69 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 19:02:01 +0400 Subject: [PATCH 20/23] refactor: remove `ConnectionPluginContext` and simplify its functionality within `Pipeline` --- src/Client/ScheduleClient.php | 10 +-- src/Client/WorkflowClient.php | 1 - src/Plugin/ConnectionPluginContext.php | 39 --------- .../Extra/Plugin/ClientPluginTest.php | 85 ++++++++++++++++++- .../ConnectionPluginContextTestCase.php | 48 ----------- 5 files changed, 87 insertions(+), 96 deletions(-) delete mode 100644 src/Plugin/ConnectionPluginContext.php delete mode 100644 tests/Unit/Plugin/ConnectionPluginContextTestCase.php diff --git a/src/Client/ScheduleClient.php b/src/Client/ScheduleClient.php index 1243f4415..d7a0ca545 100644 --- a/src/Client/ScheduleClient.php +++ b/src/Client/ScheduleClient.php @@ -33,7 +33,6 @@ use Temporal\DataConverter\DataConverterInterface; use Temporal\Internal\Mapper\ScheduleMapper; use Temporal\Internal\Interceptor\Pipeline; -use Temporal\Plugin\ConnectionPluginContext; use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginContext; @@ -64,13 +63,10 @@ public function __construct( $this->pluginRegistry = $pluginRegistry ?? new PluginRegistry(); // Apply connection plugins (before client-level configuration) - $connectionContext = new ConnectionPluginContext($serviceClient); $connectionPlugins = $this->pluginRegistry->getPlugins(ConnectionPluginInterface::class); - /** @see ConnectionPluginInterface::configureServiceClient() */ - Pipeline::prepare($connectionPlugins) - ->with(static fn() => null, 'configureServiceClient')($connectionContext); - - $serviceClient = $connectionContext->getServiceClient(); + $serviceClient = Pipeline::prepare($connectionPlugins) + /** @see ConnectionPluginInterface::configureServiceClient() */ + ->with(static fn(ServiceClientInterface $serviceClient) => $serviceClient, 'configureServiceClient')($serviceClient); $pluginContext = new ScheduleClientPluginContext( clientOptions: $this->clientOptions, diff --git a/src/Client/WorkflowClient.php b/src/Client/WorkflowClient.php index 637a60349..b73409dcf 100644 --- a/src/Client/WorkflowClient.php +++ b/src/Client/WorkflowClient.php @@ -41,7 +41,6 @@ use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; use Temporal\Plugin\CompositePipelineProvider; -use Temporal\Plugin\ConnectionPluginContext; use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\ScheduleClientPluginInterface; diff --git a/src/Plugin/ConnectionPluginContext.php b/src/Plugin/ConnectionPluginContext.php deleted file mode 100644 index 0108e1581..000000000 --- a/src/Plugin/ConnectionPluginContext.php +++ /dev/null @@ -1,39 +0,0 @@ -serviceClient; - } - - public function setServiceClient(ServiceClientInterface $serviceClient): self - { - $this->serviceClient = $serviceClient; - return $this; - } -} diff --git a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php index b23cea2f2..cf3948490 100644 --- a/tests/Acceptance/Extra/Plugin/ClientPluginTest.php +++ b/tests/Acceptance/Extra/Plugin/ClientPluginTest.php @@ -4,19 +4,27 @@ namespace Temporal\Tests\Acceptance\Extra\Plugin\ClientPlugin; +use http\Client; use PHPUnit\Framework\Attributes\Test; +use Temporal\Api\Workflowservice\V1\ListNamespacesRequest; use Temporal\Client\ClientOptions; +use Temporal\Client\GRPC\BaseClient; +use Temporal\Client\GRPC\ContextInterface; +use Temporal\Client\GRPC\ServiceClientInterface; use Temporal\Client\WorkflowClient; use Temporal\Client\WorkflowClientInterface; use Temporal\Client\WorkflowOptions; use Temporal\Client\WorkflowStubInterface; use Temporal\DataConverter\DataConverter; use Temporal\DataConverter\EncodedValues; +use Temporal\Interceptor\GrpcClientInterceptor; +use Temporal\Interceptor\SimplePipelineProvider; use Temporal\Interceptor\Trait\WorkflowClientCallsInterceptorTrait; use Temporal\Interceptor\WorkflowClient\StartInput; use Temporal\Interceptor\WorkflowClientCallsInterceptor; use Temporal\Plugin\ClientPluginContext; use Temporal\Plugin\ClientPluginInterface; +use Temporal\Plugin\ConnectionPluginInterface; use Temporal\Plugin\PluginRegistry; use Temporal\Tests\Acceptance\App\Attribute\Stub; use Temporal\Tests\Acceptance\App\Attribute\Worker; @@ -111,6 +119,47 @@ public function pluginAppliedViaWorkerAttribute( ): void { self::assertSame('plugin:world', $stub->getResult('string')); } + + /** + * Connection plugin can set custom metadata on the service client. + */ + #[Test] + public function connectionPluginSetsAuthKey( + WorkflowClientInterface $client, + State $runtime, + ): void { + $key = 'secret-api-key'; + $authPlugin = new AuthPlugin($key); + $stealer = new CredentialsStealer(); + + $workflowClient = WorkflowClient::create( + serviceClient: $client->getServiceClient(), + options: (new ClientOptions())->withNamespace($runtime->namespace), + pluginRegistry: new PluginRegistry([$authPlugin, new class($stealer) implements ConnectionPluginInterface { + public function __construct(private readonly CredentialsStealer $stealer) {} + + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface + { + if ($serviceClient instanceof BaseClient) { + $pipeline = new SimplePipelineProvider([$this->stealer]); + $serviceClient = $serviceClient->withInterceptorPipeline($pipeline->getPipeline(GrpcClientInterceptor::class)); + } + return $next($serviceClient); + } + + public function getName(): string + { + return 'test'; + } + }]), + ); + + $serviceClient = $workflowClient->getServiceClient(); + $serviceClient->ListNamespaces(new ListNamespacesRequest()); + $authKey = $stealer->getAuthKey(); + + self::assertSame("Bearer $key", $authKey); + } } @@ -162,7 +211,6 @@ public function configureClient(ClientPluginContext $context, callable $next): v } } - class PrefixInterceptor implements WorkflowClientCallsInterceptor { use WorkflowClientCallsInterceptorTrait; @@ -180,3 +228,38 @@ public function start(StartInput $input, callable $next): WorkflowExecution )); } } + +class AuthPlugin implements ConnectionPluginInterface +{ + public function __construct( + private readonly string $key, + ) {} + + public function getName(): string + { + return 'auth-plugin'; + } + + public function configureServiceClient(ServiceClientInterface $serviceClient, callable $next): ServiceClientInterface + { + return $next($serviceClient->withAuthKey($this->key)); + } +} + +class CredentialsStealer implements GrpcClientInterceptor +{ + private ?string $authKey = null; + + public function __construct() {} + + public function getAuthKey(): ?string + { + return $this->authKey; + } + + public function interceptCall(string $method, object $arg, ContextInterface $ctx, callable $next): object + { + $this->authKey = $ctx->getMetadata()['Authorization'][0]; + return $next($method, $arg, $ctx); + } +} diff --git a/tests/Unit/Plugin/ConnectionPluginContextTestCase.php b/tests/Unit/Plugin/ConnectionPluginContextTestCase.php deleted file mode 100644 index f52c8443f..000000000 --- a/tests/Unit/Plugin/ConnectionPluginContextTestCase.php +++ /dev/null @@ -1,48 +0,0 @@ -createMock(ServiceClientInterface::class); - $context = new ConnectionPluginContext($serviceClient); - - self::assertSame($serviceClient, $context->getServiceClient()); - } - - public function testSetServiceClientReplacesValue(): void - { - $original = $this->createMock(ServiceClientInterface::class); - $replacement = $this->createMock(ServiceClientInterface::class); - - $context = new ConnectionPluginContext($original); - $context->setServiceClient($replacement); - - self::assertSame($replacement, $context->getServiceClient()); - } - - public function testSetServiceClientReturnsSelf(): void - { - $context = new ConnectionPluginContext( - $this->createMock(ServiceClientInterface::class), - ); - - $result = $context->setServiceClient( - $this->createMock(ServiceClientInterface::class), - ); - - self::assertSame($context, $result); - } -} From 3265e9db461de415840ceeed9b870c6391ce7823 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Wed, 25 Mar 2026 19:28:47 +0400 Subject: [PATCH 21/23] chore: update psalm baseline --- psalm-baseline.xml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/psalm-baseline.xml b/psalm-baseline.xml index 450a9968a..b5a82271d 100644 --- a/psalm-baseline.xml +++ b/psalm-baseline.xml @@ -1,5 +1,5 @@ - + @@ -1366,7 +1366,7 @@ $converter ?? DataConverter::createDefault(), $rpc ?? Goridge::create(), $credentials, - $pluginRegistry ?? new PluginRegistry(), + $pluginRegistry, $client, )]]> @@ -1422,6 +1422,12 @@ toArray()['assets']]]> + + + + + + From e47e43c1c1efbb8c1c3c73957dd9e03868133111 Mon Sep 17 00:00:00 2001 From: Dmitriy Derepko Date: Thu, 26 Mar 2026 20:13:17 +0400 Subject: [PATCH 22/23] feat: add `runWorker` method to `WorkerPluginInterface` and integrate plugin chaining into `Server` execution flow --- src/Internal/Transport/Server.php | 17 +++++++++++++++-- src/Plugin/WorkerPluginInterface.php | 4 ++++ src/Plugin/WorkerPluginTrait.php | 7 +++++++ src/WorkerFactory.php | 16 ++++++++-------- tests/Unit/Framework/WorkerFactoryMock.php | 2 +- .../Unit/Plugin/WorkerFactoryPluginTestCase.php | 2 +- 6 files changed, 36 insertions(+), 12 deletions(-) diff --git a/src/Internal/Transport/Server.php b/src/Internal/Transport/Server.php index 03b269f82..44f8344c4 100644 --- a/src/Internal/Transport/Server.php +++ b/src/Internal/Transport/Server.php @@ -13,14 +13,19 @@ use React\Promise\PromiseInterface; use Temporal\Internal\Exception\UndefinedRequestException; +use Temporal\Internal\Interceptor\Pipeline; use Temporal\Internal\Queue\QueueInterface; use Temporal\Internal\Transport\Request\UndefinedResponse; +use Temporal\Plugin\PluginRegistry; +use Temporal\Plugin\WorkerPluginInterface; +use Temporal\Worker\DispatcherInterface; use Temporal\Worker\Transport\Command\Client\FailedClientResponse; use Temporal\Worker\Transport\Command\Client\SuccessClientResponse; use Temporal\Worker\Transport\Command\FailureResponseInterface; use Temporal\Worker\Transport\Command\RequestInterface; use Temporal\Worker\Transport\Command\ServerRequestInterface; use Temporal\Worker\Transport\Command\SuccessResponseInterface; +use Temporal\Worker\WorkerInterface; /** * @psalm-import-type OnMessageHandler from ServerInterface @@ -34,14 +39,18 @@ final class Server implements ServerInterface private \Closure $onMessage; private QueueInterface $queue; + private Pipeline $interceptors; /** * @psalm-param OnMessageHandler $onMessage */ - public function __construct(QueueInterface $queue, callable $onMessage) + public function __construct(QueueInterface $queue, callable $onMessage, PluginRegistry $pluginRegistry) { $this->queue = $queue; + $plugins = $pluginRegistry->getPlugins(WorkerPluginInterface::class); + $this->interceptors = Pipeline::prepare($plugins); + $this->onMessage($onMessage); } @@ -56,7 +65,11 @@ public function onMessage(callable $then): void public function dispatch(ServerRequestInterface $request, array $headers): void { try { - $result = ($this->onMessage)($request, $headers); + $result = $this->interceptors->with( + static fn(callable $handler, ServerRequestInterface $request, array $headers): PromiseInterface => $handler($request, $headers), + /** @see WorkerPluginInterface::runWorker() */ + 'runWorker', + )($this->onMessage, $request, $headers); } catch (\Throwable $e) { $this->queue->push(new FailedClientResponse($request->getID(), $e)); diff --git a/src/Plugin/WorkerPluginInterface.php b/src/Plugin/WorkerPluginInterface.php index fded696aa..ca2484481 100644 --- a/src/Plugin/WorkerPluginInterface.php +++ b/src/Plugin/WorkerPluginInterface.php @@ -11,6 +11,8 @@ namespace Temporal\Plugin; +use React\Promise\PromiseInterface; +use Temporal\Worker\Transport\Command\ServerRequestInterface; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; @@ -77,4 +79,6 @@ public function initializeWorker(WorkerInterface $worker, callable $next): void; * @param callable(WorkerFactoryInterface): int $next Calls the next plugin or the actual run loop. */ public function run(WorkerFactoryInterface $factory, callable $next): int; + + public function runWorker(callable $handler, ServerRequestInterface $request, array $headers, callable $next): PromiseInterface; } diff --git a/src/Plugin/WorkerPluginTrait.php b/src/Plugin/WorkerPluginTrait.php index d47965bce..c8ef97dc8 100644 --- a/src/Plugin/WorkerPluginTrait.php +++ b/src/Plugin/WorkerPluginTrait.php @@ -11,6 +11,8 @@ namespace Temporal\Plugin; +use React\Promise\PromiseInterface; +use Temporal\Worker\Transport\Command\ServerRequestInterface; use Temporal\Worker\WorkerFactoryInterface; use Temporal\Worker\WorkerInterface; @@ -40,4 +42,9 @@ public function run(WorkerFactoryInterface $factory, callable $next): int { return $next($factory); } + + public function runWorker(callable $handler, ServerRequestInterface $request, array $headers, callable $next): PromiseInterface + { + return $next($handler, $request, $headers); + } } diff --git a/src/WorkerFactory.php b/src/WorkerFactory.php index 7fea8e649..c64245815 100644 --- a/src/WorkerFactory.php +++ b/src/WorkerFactory.php @@ -327,7 +327,7 @@ protected function createClient(): ClientInterface protected function createServer(): ServerInterface { - return new Server($this->responses, $this->onRequest(...)); + return new Server($this->responses, $this->onRequest(...), $this->pluginRegistry); } /** @@ -391,22 +391,22 @@ private function onRequest(ServerRequestInterface $request, array $headers): Pro return $this->router->dispatch($request, $headers); } - $queue = $this->findTaskQueueOrFail( + $worker = $this->findWorkerByTaskQueue( $this->findTaskQueueNameOrFail($headers), ); - return $queue->dispatch($request, $headers); + return $worker->dispatch($request, $headers); } - private function findTaskQueueOrFail(string $taskQueueName): WorkerInterface + private function findWorkerByTaskQueue(string $taskQueue): WorkerInterface { - $queue = $this->queues->find($taskQueueName); + $worker = $this->queues->find($taskQueue); - if ($queue === null) { - throw new \OutOfRangeException(\sprintf(self::ERROR_QUEUE_NOT_FOUND, $taskQueueName)); + if ($worker === null) { + throw new \OutOfRangeException(\sprintf(self::ERROR_QUEUE_NOT_FOUND, $taskQueue)); } - return $queue; + return $worker; } private function findTaskQueueNameOrFail(array $headers): string diff --git a/tests/Unit/Framework/WorkerFactoryMock.php b/tests/Unit/Framework/WorkerFactoryMock.php index 6006a486f..994c10b3b 100644 --- a/tests/Unit/Framework/WorkerFactoryMock.php +++ b/tests/Unit/Framework/WorkerFactoryMock.php @@ -210,7 +210,7 @@ private function createRouter(): RouterInterface private function createServer(): ServerInterface { - return new Server($this->responses, \Closure::fromCallable([$this, 'onRequest'])); + return new Server($this->responses, \Closure::fromCallable([$this, 'onRequest']), $this->pluginRegistry); } private function createMarshaller(ReaderInterface $reader): MarshallerInterface diff --git a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php index fe12b6ec9..e91148da2 100644 --- a/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php +++ b/tests/Unit/Plugin/WorkerFactoryPluginTestCase.php @@ -545,7 +545,7 @@ public function testRunHookCanSkipNext(): void { $innerCalled = false; - $outerPlugin = new class() implements WorkerPluginInterface { + $outerPlugin = new class implements WorkerPluginInterface { use WorkerPluginTrait; public function getName(): string From b07188974b2276df2b03dbc0a0904205dcd42555 Mon Sep 17 00:00:00 2001 From: github-actions Date: Thu, 26 Mar 2026 16:13:52 +0000 Subject: [PATCH 23/23] style(php-cs-fixer): fix coding standards --- src/Internal/Transport/Server.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/Internal/Transport/Server.php b/src/Internal/Transport/Server.php index 44f8344c4..c902232a0 100644 --- a/src/Internal/Transport/Server.php +++ b/src/Internal/Transport/Server.php @@ -18,14 +18,12 @@ use Temporal\Internal\Transport\Request\UndefinedResponse; use Temporal\Plugin\PluginRegistry; use Temporal\Plugin\WorkerPluginInterface; -use Temporal\Worker\DispatcherInterface; use Temporal\Worker\Transport\Command\Client\FailedClientResponse; use Temporal\Worker\Transport\Command\Client\SuccessClientResponse; use Temporal\Worker\Transport\Command\FailureResponseInterface; use Temporal\Worker\Transport\Command\RequestInterface; use Temporal\Worker\Transport\Command\ServerRequestInterface; use Temporal\Worker\Transport\Command\SuccessResponseInterface; -use Temporal\Worker\WorkerInterface; /** * @psalm-import-type OnMessageHandler from ServerInterface